Threat model¶
This page lists threats, what Sluice does against each, and what you must do.
Trust boundaries¶
flowchart LR
subgraph clients["Client network"]
A["AI agent (MCP)"]
B["BI tool (REST)"]
end
subgraph gateway["Sluice process"]
I[Identity] --> P[Policy engine] --> R[Rewriter] --> E[DuckDB executor]
E --> AU[(Hash-chained audit)]
end
subgraph ops["Private network"]
O[Operator] --> ADM["Admin API :9091"]
end
subgraph data["Data plane"]
DB[("Postgres / MySQL / S3 / ...")]
end
A -->|"TLS"| I
B -->|"TLS"| I
E -->|"read-only ATTACH"| DB
ADM -.-> P REST, MCP over HTTP and admin serve plain HTTP until you set tls.
Threats¶
| Threat | What Sluice does | What you must do |
|---|---|---|
A client reads a table that no policy allows, also in a join, subquery or UNION | Denies the query: ACL_DENIED | Allow only the tables that clients may read |
A client writes with INSERT, UPDATE, DELETE or MERGE | Rejects the statement: ACL_REJECTED. Attaches databases READ_ONLY | Give Sluice a read-only database user |
A client sends DDL, COPY, BEGIN, ATTACH, INSTALL or LOAD | Refuses the statement: ERR_UNSUPPORTED_SYNTAX, or ERR_SYNTAX for the last three | Nothing |
| A client sends two statements in one request | Refuses the request: ERR_MULTIPLE_STATEMENTS | Nothing |
A client calls a function that reads files or settings, such as read_csv or duckdb_settings | Runs only functions on its allow list. Others: ACL_REJECTED | Nothing |
| A client reads rows of another tenant | A RowFilterPolicy filters every read of the table. The filter value is a bound parameter | Write a filter for every tenant table. Keep the filter column in every matched table. Without it, reads fail: ERR_QUERY_FAILED |
| A client reads a PII column | A ColumnMaskPolicy replaces the values of the column | Pick the mask type. partial shows a prefix. Unsalted sha256 values can be guessed |
A client reads policy SQL or bound values with EXPLAIN | Refuses EXPLAIN on a table with a row filter or column mask: ERR_UNSUPPORTED_SYNTAX | Nothing |
A client reads bound values with $n parameters | Refuses the statement: ERR_UNSUPPORTED_SYNTAX | Nothing |
| A client gets around a policy with SQL that Sluice and DuckDB read differently | Rejects a two-part table name such as shop.customers: ACL_REJECTED. Refuses a name over 63 bytes. Before a rewrite, checks that DuckDB reads the SQL the same way: ERR_UNSUPPORTED_SYNTAX or ERR_SYNTAX | Write catalog.schema.table |
| A client dumps a table in one query | A QueryRewritePolicy sets a row limit, a sample and a timeout. limits.maxRowsCeiling caps the rows of every result, limits.maxResultBytes its size, also for one list() or string_agg() row that holds a whole table. DuckDB still builds that row in memory | Add a QueryRewritePolicy on tables: ["**"] |
| A client dumps a table over many queries | Daily budget per subject on result rows and run time: ERR_BUDGET_EXCEEDED. The query that crosses the budget is still served | Set budget.enabled: true. Set budget on the subject binding, with claims.subjectId or issuer |
| A client floods Sluice with requests | On the REST and MCP listeners, global and per-IP rate limits before login, with one budget for both. One rate limit per subject after login: ERR_RATE_LIMITED | Set rateLimit on the subject binding, with claims.subjectId or issuer. limits.perIpRps is off by default. Behind a proxy all clients have one address |
| A client sends a risky query form | A QueryRejectPolicy rule rejects it: ACL_REJECTED | Write the rules |
| A client runs a sensitive query that nobody approved | An ApprovalPolicy holds the query until an approver accepts it: ERR_APPROVAL_PENDING. A grant runs the same query once. Accept and reject links need only their token | Set approval.publicBaseUrl and a webhook. Keep the webhook channel private. Run one instance |
| A stolen API key | Stores only an HMAC of each key with a pepper. Rate limit and budget, when set, cap its use | Keep the pepper secret. Use one key id per client. Rotate keys |
| A forged JWT | Checks signature, algorithm and issuer, and the audience when set. Refuses alg: none | Set issuer, audience and jwksUrl |
| A changed audit log | Hash chain. sluice audit verify exits with status 4 and names file and line. Without a chain key it misses a change when the later hashes were computed again. It always misses removed newest records | Set audit.chainKeyRef. Run verify with --anchor on a schedule. Keep a copy and each last_hash on another host and compare, see Audit log |
| A query without an audit record | audit.failClosed: ERR_AUDIT_UNAVAILABLE when the audit queue does not take the record. A failed disk write does not stop queries | Keep it true. Alert on sluice_audit_write_errors_total |
| An open admin API | Own listener, off by default, bearer token | Set admin.token in the server config. Bind admin.listen to a private interface |
| Traffic read on the network | TLS and client certificates on the REST, MCP and admin listeners, TLS 1.2 or newer | Set rest.tls, mcp.tls and admin.tls, or use a TLS proxy |
| A secret file open to other users | Refuses a file that group or others can write. Warns when others can read it | chmod 0600 |
| DuckDB reads local files or other URLs | Turns off external access and extension install, locks the settings. Only the configured S3 bucket and HTTP URLs stay readable | List only the URLs you need |
| A build reads raw data and bypasses policies | Only sluice synth build, the admin API and build.onStart start a build. Server builds write an audit record. The query pool reads only the snapshot folder | Protect the admin token and the CI job that runs sluice synth build. Keep reports private |
| A synthetic snapshot reveals counts of the source | minGroupSize, and with noise Laplace noise with a budget per build | Set noise and bounds for sources with personal data. Keep the seed secret. Each rebuild over changed data spends the budget again |
| A user with database credentials | Nothing. This user does not pass through Sluice | Give database credentials only to Sluice |
| A person who controls the host | Nothing. This person can read every secret and change policies | Protect the host |
| The holder of the admin token | Nothing. The admin API shows policies and audit records | Give the token to operators only |
| A subject learns about one person from many counts and sums | Nothing | Allow such subjects only tables without personal data |
In most rows, a statement that no allow policy matches gets ACL_DENIED instead of the row's code. With resources in every allow policy, this covers BEGIN, ATTACH, INSTALL, LOAD and most DDL.
Example¶
One policy allows the table customers. A column mask covers customers.email. The client asks for a table that no policy allows:
curl -s http://localhost:8080/v1/query \
-H "X-Api-Key: sl_demo_agent.supersecret" \
-H "Content-Type: application/json" \
-d '{"sql": "SELECT * FROM shop.main.admin_users"}'
{
"code": "ACL_DENIED",
"message": "no SqlAccessPolicy matched (default-deny)",
"query_id": "<query-id>"
}
The client sends a write:
curl -s http://localhost:8080/v1/query \
-H "X-Api-Key: sl_demo_agent.supersecret" \
-H "Content-Type: application/json" \
-d '{"sql": "DELETE FROM shop.main.customers"}'
{
"code": "ACL_REJECTED",
"message": "write operations are not permitted",
"query_id": "<query-id>"
}
The client sends EXPLAIN on the masked table:
curl -s http://localhost:8080/v1/query \
-H "X-Api-Key: sl_demo_agent.supersecret" \
-H "Content-Type: application/json" \
-d '{"sql": "EXPLAIN SELECT id FROM shop.main.customers"}'
{
"code": "ERR_UNSUPPORTED_SYNTAX",
"message": "EXPLAIN is not supported on a table with a row filter or column mask",
"query_id": "<query-id>"
}
What happened: Sluice refused each statement before it reached DuckDB.
Limits¶
- Sluice does not control what a client does with the rows it reads.
EXPLAINon a table without a row filter or column mask is served. The plan can show the file path of a data source.rowsPerDaycounts result rows too.