Security
pg_ripple provides multiple layers of security: PostgreSQL's native authentication and authorization, named-graph row-level security (RLS), SQL injection prevention through dictionary encoding, and secure configuration of the pg_ripple_http companion service.
Authentication and Authorization
pg_ripple relies entirely on PostgreSQL's built-in authentication (pg_hba.conf) and role-based access control. There is no separate user database.
Minimum Privileges for SPARQL Queries
-- Create a read-only role
CREATE ROLE sparql_reader LOGIN PASSWORD 'strong_password';
GRANT USAGE ON SCHEMA pg_ripple TO sparql_reader;
GRANT USAGE ON SCHEMA _pg_ripple TO sparql_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA _pg_ripple TO sparql_reader;
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA pg_ripple TO sparql_reader;
Minimum Privileges for Data Loading
-- Create a writer role
CREATE ROLE sparql_writer LOGIN PASSWORD 'strong_password';
GRANT USAGE ON SCHEMA pg_ripple TO sparql_writer;
GRANT USAGE ON SCHEMA _pg_ripple TO sparql_writer;
GRANT SELECT, INSERT, DELETE ON ALL TABLES IN SCHEMA _pg_ripple TO sparql_writer;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA _pg_ripple TO sparql_writer;
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA pg_ripple TO sparql_writer;
Use ALTER DEFAULT PRIVILEGES to ensure newly created VP tables (created when new predicates are encountered) inherit the correct grants:
ALTER DEFAULT PRIVILEGES IN SCHEMA _pg_ripple
GRANT SELECT ON TABLES TO sparql_reader;
ALTER DEFAULT PRIVILEGES IN SCHEMA _pg_ripple
GRANT SELECT, INSERT, DELETE ON TABLES TO sparql_writer;
Named-Graph Row-Level Security
pg_ripple supports fine-grained access control at the named-graph level using PostgreSQL's row-level security (RLS) infrastructure. This allows different users to see different subsets of the knowledge graph.
Enabling Graph RLS
-- Enable RLS on all VP tables
SELECT pg_ripple.enable_graph_rls();
This creates RLS policies on every VP table (including vp_rare) that filter rows based on the g (graph) column.
Granting Graph Access
-- Grant a role access to a specific named graph
SELECT pg_ripple.grant_graph('sparql_reader', 'http://example.org/confidential');
-- Grant access to the default graph (g = 0)
SELECT pg_ripple.grant_graph('sparql_reader', '');
-- Grant access to all graphs
SELECT pg_ripple.grant_graph('sparql_reader', '*');
Revoking Graph Access
-- Revoke access to a specific graph
SELECT pg_ripple.revoke_graph('sparql_reader', 'http://example.org/confidential');
How It Works
When graph RLS is enabled:
- Each VP table gets an RLS policy that checks the
gcolumn against the user's allowed graph IDs - The dictionary encodes graph IRIs to
i64identifiers - An internal mapping table (
_pg_ripple.graph_grants) stores(role, graph_id)pairs - PostgreSQL enforces the policy transparently — SPARQL queries automatically filter results
PostgreSQL superusers bypass RLS by default. To enforce graph security even for superusers, the user must explicitly SET row_security = on and not be a table owner. For production, use non-superuser roles for application connections.
Example: Multi-Tenant Knowledge Graph
-- Create tenant roles
CREATE ROLE tenant_a LOGIN PASSWORD 'pw_a';
CREATE ROLE tenant_b LOGIN PASSWORD 'pw_b';
-- Grant base access
GRANT USAGE ON SCHEMA pg_ripple TO tenant_a, tenant_b;
GRANT USAGE ON SCHEMA _pg_ripple TO tenant_a, tenant_b;
GRANT SELECT ON ALL TABLES IN SCHEMA _pg_ripple TO tenant_a, tenant_b;
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA pg_ripple TO tenant_a, tenant_b;
-- Enable graph RLS
SELECT pg_ripple.enable_graph_rls();
-- Tenant A sees only their graph
SELECT pg_ripple.grant_graph('tenant_a', 'http://example.org/tenant-a');
-- Tenant B sees only their graph
SELECT pg_ripple.grant_graph('tenant_b', 'http://example.org/tenant-b');
-- Both see shared reference data
SELECT pg_ripple.grant_graph('tenant_a', 'http://example.org/shared');
SELECT pg_ripple.grant_graph('tenant_b', 'http://example.org/shared');
Now SPARQL queries run by tenant_a will only see triples in tenant-a and shared graphs, with no application-level filtering required.
SQL Injection Prevention
pg_ripple's architecture provides strong defense against SQL injection by design.
Dictionary Encoding as a Security Layer
All SPARQL queries go through a multi-step translation pipeline:
- Parse: SPARQL text is parsed by
spargebrainto an abstract algebra tree - Encode: All bound constants (IRIs, literals) are dictionary-encoded to
i64integers before SQL generation - Generate: SQL is constructed using parameterized queries with integer placeholders
- Execute: SQL runs via
pgrx::SpiClientwith bound parameters
Because VP tables store only BIGINT columns (s, o, g, i, source), there is no surface for string-based SQL injection. Even if a malicious IRI is passed in a SPARQL query, it is hashed to an integer before any SQL is generated.
Table Name Safety
VP table references use OID lookups from _pg_ripple.predicates, not string concatenation:
#![allow(unused)] fn main() { // Internal: table names are never interpolated from user input let table_oid = predicates::get_table_oid(predicate_id)?; // SQL uses the OID directly: FROM pg_class WHERE oid = $1 }
User-Facing Function Safety
Functions that accept text input (like pg_ripple.sparql()) parse the SPARQL text through spargebra, which rejects anything that is not valid SPARQL. No raw SQL is passed through.
File-Path Loaders and Superuser Requirement
Functions that read from the server's filesystem require superuser privileges:
| Function | Requires Superuser | Reason |
|---|---|---|
pg_ripple.load_turtle_file(path) | Yes | Reads arbitrary filesystem paths |
pg_ripple.load_ntriples_file(path) | Yes | Reads arbitrary filesystem paths |
pg_ripple.load_rdfxml_file(path) | Yes | Reads arbitrary filesystem paths |
pg_ripple.load_turtle(text) | No | Parses in-memory text only |
pg_ripple.load_ntriples(text) | No | Parses in-memory text only |
File-path loaders can read any file the PostgreSQL process has access to. Never grant superuser to application roles. Instead, load data as a superuser and grant read access to application roles via schema permissions.
Safe Bulk Load Pattern
-- As superuser: load the data
SELECT pg_ripple.load_turtle_file('/data/import/dataset.ttl');
-- As superuser: grant access to the app role
GRANT SELECT ON ALL TABLES IN SCHEMA _pg_ripple TO app_role;
pg_ripple_http Security
The pg_ripple_http companion service exposes a SPARQL Protocol endpoint over HTTP. Secure it appropriately.
TLS Configuration
Always run pg_ripple_http behind TLS in production:
# pg_ripple_http.toml
[server]
bind = "0.0.0.0:8443"
tls_cert = "/etc/ssl/certs/pg_ripple_http.crt"
tls_key = "/etc/ssl/private/pg_ripple_http.key"
SPARQL queries may contain sensitive data patterns. Without TLS, queries and results are transmitted in plaintext. Always terminate TLS either at the service or at a reverse proxy.
Authentication
Configure pg_ripple_http to authenticate incoming requests:
[auth]
# HTTP Basic authentication backed by PostgreSQL roles
method = "pg_role"
# Or use a static API key
# method = "api_key"
# api_key = "your-secret-key-here"
With pg_role authentication, HTTP Basic credentials are forwarded to PostgreSQL. Graph RLS policies apply to the authenticated role.
Reverse Proxy Setup
For production, place pg_ripple_http behind a reverse proxy:
# nginx configuration
server {
listen 443 ssl;
server_name sparql.example.org;
ssl_certificate /etc/ssl/certs/sparql.crt;
ssl_certificate_key /etc/ssl/private/sparql.key;
location /sparql {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Rate limiting
limit_req zone=sparql burst=20 nodelay;
}
}
CORS Configuration
If the SPARQL endpoint is accessed from browser applications:
[cors]
allowed_origins = ["https://app.example.org"]
allowed_methods = ["GET", "POST"]
allowed_headers = ["Content-Type", "Authorization"]
max_age = 3600
Do not set allowed_origins = ["*"] in production. This allows any website to send SPARQL queries to your endpoint using the visitor's credentials.
Network Isolation
Production Topology
┌─────────────┐ TLS ┌──────────────────┐ Unix socket ┌─────────────┐
│ Clients │ ────────────→ │ pg_ripple_http │ ──────────────────→ │ PostgreSQL │
│ │ │ (reverse proxy) │ │ (pg_ripple) │
└─────────────┘ └──────────────────┘ └─────────────┘
Recommendations
- PostgreSQL: bind to
localhostor a private network interface only. Never expose port 5432 to the public internet.
# postgresql.conf
listen_addresses = '127.0.0.1'
-
pg_ripple_http: connect to PostgreSQL via Unix socket for lowest latency and no network exposure.
-
Firewall rules: only allow traffic on the HTTPS port (443) from expected client networks.
# iptables example
iptables -A INPUT -p tcp --dport 443 -s 10.0.0.0/8 -j ACCEPT
iptables -A INPUT -p tcp --dport 443 -j DROP
iptables -A INPUT -p tcp --dport 5432 -j DROP
- pg_hba.conf: restrict connections by source IP and authentication method:
# TYPE DATABASE USER ADDRESS METHOD
local all postgres peer
host mydb pg_ripple_http 127.0.0.1/32 scram-sha-256
host mydb sparql_reader 10.0.0.0/8 scram-sha-256
host all all 0.0.0.0/0 reject
Always use scram-sha-256 authentication (the default in PostgreSQL 18). Avoid md5 and never use trust in production.
Security Checklist
| Item | Status |
|---|---|
shared_preload_libraries includes only trusted extensions | ☐ |
| Non-superuser roles used for all application connections | ☐ |
| Graph RLS enabled for multi-tenant deployments | ☐ |
pg_hba.conf restricts connections to known networks | ☐ |
TLS enabled on pg_ripple_http or reverse proxy | ☐ |
| File-path loaders restricted to superuser only (default) | ☐ |
synchronous_commit enabled for production (not off) | ☐ |
Connection pooler uses scram-sha-256 | ☐ |
| CORS origins are not wildcarded | ☐ |
| PostgreSQL logs enabled for audit trail | ☐ |
| Regular security updates for PostgreSQL and pg_ripple | ☐ |
Planned v1.0.0 External Audit
After v0.132.0, the project plans an independent security audit covering the
Rust extension, HTTP companion, SQL migrations, Docker images, and Helm chart.
The requested review scope includes OWASP ASVS level 2, PostgreSQL extension
attack surfaces, SSRF, injection, authentication bypass, and privilege
escalation. The engagement brief and deliverables checklist are tracked in
audit/engagement-brief.md.
The audit is planned to start within four weeks of the v0.132.0 tag. Its report and remediation confirmation are release evidence for v1.0.0; this section is not a claim that the audit has completed.
Audit Logging
Enable PostgreSQL's logging to maintain an audit trail:
# postgresql.conf
log_statement = 'all' # or 'ddl' for schema changes only
log_connections = on
log_disconnections = on
log_line_prefix = '%t [%p] %u@%d '
For fine-grained audit logging, consider the pgaudit extension alongside pg_ripple.
pg_ripple logs the generated SQL via PostgreSQL's standard statement logging. To see the original SPARQL text, enable log_statement = 'all' — the SPARQL text appears as the argument to pg_ripple.sparql().
Log-Hook Audit and Secret Suppression (LOG-HOOK-01)
v0.76.0 audit finding: No
RegisterEmitLogHookis installed to suppress secrets from PostgreSQL error messages. A defense-in-depth audit was performed to verify that no error or warning call site leaks raw secrets.
Audit Results
The following audit was performed on all pgrx::error!(), pgrx::warning!(), tracing::error!(), and tracing::warn!() call sites in the pg_ripple extension and pg_ripple_http companion service:
| Component | Secrets handled | Logged in errors? | Notes |
|---|---|---|---|
src/security_api.rs | None (policy names, graph IRIs) | No | Only structural metadata |
src/llm/mod.rs | LLM API key (env var) | No | Key is read from env at call time and not referenced in error paths |
pg_ripple_http auth check | Bearer token (HMAC comparison) | No | check_token uses constant_time_eq; returns 401 without token value |
pg_ripple_http Arrow Flight | HMAC signing secret | No | Error returns generic "HMAC key error: {e}" — e is a key-length error only |
pg_ripple_http/src/common.rs | auth_token, datalog_write_token | No | Tokens stored in AppState but never interpolated into error messages |
pg_ripple_http/src/main.rs | ARROW_FLIGHT_SECRET (env var) | No | Env var read at startup; not referenced in tracing calls |
No RegisterEmitLogHook Required
Based on the audit, no error path in the current codebase logs raw HMAC keys, connection strings, bearer tokens, or other credentials. A RegisterEmitLogHook is therefore not required at this time.
If you add a new error path that touches secrets, follow these guidelines:
- Do not interpolate secret values (
token,key,password) intopgrx::error!()orpgrx::warning!()messages. - Use descriptive error types without values (e.g.,
"HMAC key error: invalid key length"not"bad key: {key_value}"). - If a future release handles user-supplied credentials in a hot path, consider installing a
RegisterEmitLogHookto scrub sensitive patterns from log lines.
Connection Strings
PostgreSQL connection strings (DSN) containing passwords may appear in pg_log via log_connections = on. Use pg_hba.conf with scram-sha-256 and a connection pooler (e.g., PgBouncer) that authenticates separately so application DSNs never include inline passwords.
Supported Authentication Schemes (S13-10, v0.86.0)
pg_ripple_http accepts Bearer token authentication only.
| Scheme | Supported | Notes |
|---|---|---|
| Bearer (RFC 6750) | ✅ Yes | Set PG_RIPPLE_HTTP_AUTH_TOKEN env var; pass Authorization: Bearer <token> |
| Basic (RFC 7617) | ❌ No | Not accepted; Basic sends credentials in cleartext (even over TLS, base64 is trivially reversible) |
| API Key (header) | ❌ No | Not a standard HTTP auth scheme; use Bearer instead |
| mTLS | ⏳ Future | Planned for v1.x |
Recommendation: rotate the Bearer token regularly. Use a 32-byte (256-bit) or larger random secret:
openssl rand -base64 32
Set the token as an environment variable or via Docker secrets — never hardcode it in docker-compose.yml or configuration files.
Metrics Port Isolation (S13-09, v0.86.0)
The GET /metrics endpoint exposes Prometheus-format operational data including query counts,
error rates, cache statistics, and federation endpoint information. This endpoint does not
require authentication — it relies on network-level isolation instead.
Do not expose the metrics port (default: same as the API port 7878) to untrusted networks.
In production deployments:
- Run
pg_ripple_httpin a private network (e.g., Kubernetes pod network, Docker internal network). - Use a reverse proxy (nginx, Envoy, Traefik) to expose only
/sparql,/datalog, and/healthexternally. - Restrict
/metricsto Prometheus scraper IPs via the reverse proxy ACL. - Consider deploying
pg_ripple_httpwith a separate metrics port binding (future feature).
CORS permissive mode warning: when PG_RIPPLE_HTTP_CORS_ORIGINS=* is set, every
cross-origin request increments the pg_ripple_http_cors_permissive_requests_total Prometheus
counter. Monitor this counter to detect unexpected cross-origin traffic. Use a specific origin
allowlist in production (e.g., PG_RIPPLE_HTTP_CORS_ORIGINS=https://app.example.com).