Agentic Coop DB forwards arbitrary valid SQL to Postgres. The security story therefore has to be airtight: parameterisation, authentication, authorisation, multi-tenant isolation, and Postgres-side hardening are five layers of defence, each independently sufficient for its threat.
- Placeholder–parameter consistency. The endpoint body is
{sql, params}. The validator parses the SQL withpg_query.Scanand counts$Nplaceholders; if the count does not matchlen(params)the request is rejected with HTTP 400. Queries with zero placeholders and zero params are allowed (e.g.SELECT 1, DDL). - Single statement only.
pg_query.Parsereturns a list of top-level statements; the validator rejects anything other than a list of length 1. This blocks the canonical'; DROP TABLE users; --chain. - Statement size cap (default 256 KiB, tunable via
AGENTCOOPDB_MAX_STATEMENT_BYTES) and parameter count cap (default 1 000, tunable viaAGENTCOOPDB_MAX_STATEMENT_PARAMS). - Server-side parameter binding. The executor uses pgx's positional
binding (
tx.Query(ctx, sql, args...)); values are sent as a separate field on the wire and never interpolated into the SQL text. - SDK ergonomics push parameterisation.
db.execute(sql, params)is easier to use than building an f-string. A future release will includeagentcoopdb-lint, a tiny ast-based linter that flagsdb.execute(f"...{x}...")patterns.
- API keys are 192-bit secrets stored as argon2id hashes
(
time=2, memory=64 MiB, threads=2). The plaintext is shown to the caller exactly once at creation time. - Verification uses an in-memory LRU+TTL cache keyed on
sha256(full_key). Argon2id runs at most once per key per 5 minutes. - The header is
Authorization: Bearer acd_<env>_<id>_<secret>. The<id>is used for the lookup, the<secret>is verified against the hash. Comparison is constant-time. - TLS is mandatory outside of localhost. The server refuses to start
with
AGENTCOOPDB_INSECURE_HTTP=1unless that env var is explicitly set. - MCP requests (when
AGENTCOOPDB_MCP_ENABLED=true) go through the same auth middleware and per-key rate limiter as REST API requests. SSE connections are bounded by a 30-minute session idle timeout to prevent connection hoarding.
- Schema split. The gateway's control-plane (
workspaces,api_keys,audit_logs,idempotency_keys,rpc_registry) lives in a dedicatedagentcoopdbschema.dbadminanddbuserAPI keys have no grants on that schema, so adbadminkey cannotDROP TABLE api_keysorSELECT FROM audit_logseven though they own thepublicschema. See migration0007_split_control_plane_schema. - Pool login role:
agentcoopdb_gateway. No privileges of its own beyond CRUD on theagentcoopdbschema; member ofdbadmin,dbuser, and any custom roles minted later. - Every request opens a transaction and runs
SET LOCAL ROLE '<key.role>'. BecauseSET LOCAL ROLEcan only target roles the outer role is a member of, adbuserkey cannot escalate todbadmin. dbadminhasCREATEROLE CREATEDB BYPASSRLS, owns thepublicschema, and can run DDL/DCL. Not a Postgres superuser, so cannotALTER SYSTEM,COPY ... FROM PROGRAM, or load arbitrary libraries.dbuserisNOBYPASSRLS— RLS policies on tenant tables apply unconditionally.- Migrations run as a separate role
agentcoopdb_ownervia a different connection string. The application server never opens a connection asagentcoopdb_owner.
internal/tenant.SetuprunsSELECT set_config('app.workspace_id', $1, true)inside the request transaction.- Every tenant table has
ENABLE+FORCE ROW LEVEL SECURITYand a policy:USING (workspace_id = current_setting('app.workspace_id', true)::uuid) WITH CHECK (workspace_id = current_setting('app.workspace_id', true)::uuid)
current_setting(..., true)returns NULL when unset; the comparison is NULL, the policy denies — fail closed.scripts/lint-migrationsis a CI gate that fails any PR adding a tenant table without the policy.test/security/cross_tenant_test.gois a matrix test: workspace B's key attempts every endpoint against workspace A's data. Every attempt must return zero rows or a permission error.
- Migration
0004_roles_and_grants.up.sqlrevokesEXECUTEon filesystem escape functions from PUBLIC, dbadmin, and dbuser:pg_read_file,pg_read_binary_file,pg_ls_dirlo_import,lo_export
dblink,file_fdw,plpython3u,plperluextensions are not installed.COPY ... FROM PROGRAMrequirespg_execute_server_program, which is not granted todbadminordbuser.postgresql.conf(every profile) sets:log_min_duration_statement = 500 log_statement = ddl log_connections = on log_disconnections = on password_encryption = scram-sha-256 ssl = on # cloud profile- The cloud profile binds Postgres to a private docker network only — it is not published on the host and not reachable from outside the Caddy reverse proxy. The only externally reachable port is 443.
- Audit log (
audit_logstable): every authenticated request writes a row withrequest_id, workspace_id, key_id, endpoint, command, sql_hash, params_hash, duration_ms, error_code, client_ip. Metadata (request_id, workspace_id, endpoint, command, duration, status, error_code, sqlstate, client_ip) goes to the slog stream by default. Full SQL text and params are stored in theaudit_logstable only whenAGENTCOOPDB_AUDIT_INCLUDE_SQL=trueis set. SetAGENTCOOPDB_AUDIT_DISABLED=trueto skip allaudit_logstable writes entirely (slog access logging still fires); useful in development or high-throughput environments where DB audit overhead is undesirable. - Client IP: by default the server uses the TCP peer address for audit
logging. Set
AGENTCOOPDB_TRUST_PROXY=trueonly when running behind a trusted reverse proxy (Caddy, nginx, cloud LB) to useX-Real-Ip/X-Forwarded-Forheaders. - Rate limiting: per-key token bucket, default 60 req/s sustained / 120 burst. Every response carries
X-RateLimit-LimitandX-RateLimit-Remainingheaders so clients can track headroom. 429 responses additionally includeRetry-After: 1. - Request size limits: 1 MiB request body, 8 MiB response body default.
- Timeouts: read header 5s, read 10s, write 30s, idle 120s, statement timeout 5s (per request, configurable up to 60s).
- Secrets: file-backed in compose,
external: truein swarm. No secrets in environment variables in production profiles. - Container hardening: Alpine final image (instead of distroless, to
allow
docker execfor admin tasks like key minting),USER 65532:65532, read-only root filesystem,cap_drop: [ALL],no-new-privileges. - Dependency scanning:
govulncheckandpip-auditin CI on every PR. CodeQL on the default branch. - Vulnerability suppression: known-safe advisories that cannot be resolved
by upgrading (e.g. test-only transitive deps, vulnerabilities requiring a
different module path) are documented and time-boxed in
osv-scanner.toml. Suppressions are reviewed on or before theirignoreUntildate.
Use GitHub Security Advisories. Do not open a public issue. Critical fixes get a CVE and a patch release within 7 days of confirmed report.