diff --git a/CHANGELOG.md b/CHANGELOG.md
index 45a32725..65a4e470 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -37,6 +37,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Fixed
+- **Schema-qualified table names**: Proxy now resolves a schema-qualified name such as `public.users` to the table in that schema, so statements that use one are encrypted instead of failing type checking. This includes a table whose name is also used in another schema on the search path. Proxy keys encryption configuration by the unqualified table name, so when same-named tables in different schemas declare a column with different EQL domains, statements on that column are refused rather than encrypted with the other table's configuration. Same-named tables that declare the same domain share one configuration and key descriptor, so their ciphertexts and search terms are interchangeable.
+
- **Prepared statement recovery and settings isolation**: failed or discarded protocol messages no longer replace encryption metadata for statements PostgreSQL retained. Pipelined close-and-reprepare recovers immediately, and refused operations use names that remain unpredictable within PostgreSQL's lookup limit. Unsupported keyset values fail explicitly, and stale prepared statements signal driver cache invalidation.
- **Ordinary transaction and COPY traffic**: transaction and reset batches that do not change effective CipherStash settings remain usable, and COPY input can progress while transaction outcomes are pending. Settings tracking avoids unnecessary transaction barriers and repeated SQL tree copies.
@@ -69,6 +71,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- **Bind to a statement Proxy did not see prepared**: Proxy now refuses a Bind that names a prepared statement it has no Parse for on the connection, with PostgreSQL's own `26000` error, and the connection stays open. Proxy keeps encryption details only for statements that touch encrypted columns, so before this change it could not tell an unknown statement from a plaintext one and sent the bound values as they were. This includes statements created with SQL `PREPARE` and then bound through the extended protocol; prepare them with Parse instead. The check does not apply while mapping is disabled. See [Unknown prepared statement](docs/errors.md#mapping-unknown-prepared-statement).
+- **Writes to a table Proxy cannot resolve**: when encrypted columns are configured, Proxy now refuses an `INSERT`, `UPDATE`, `DELETE` or `MERGE` that refers to a table missing from the database schema it has loaded, whether or not `CS_DEVELOPMENT__ENABLE_MAPPING_ERRORS` is set. Previously such a statement failed type checking and was forwarded as written, so its values could reach PostgreSQL in plaintext. A simple query is also no longer forwarded as written when one statement fails type checking and another needs encrypting. See [Unresolvable table](docs/errors.md#mapping-unresolvable-table).
+
+ Compatibility note: while encrypted columns are configured, Proxy now refuses writes to tables in schemas outside the search path it loads, to views and foreign tables (Proxy loads only base tables), and writes that use a bare table name found in more than one schema on that search path. Before upgrading, check that applications do not write to such tables through Proxy, or qualify the table name with its schema.
+
- **Short encryption results**: Proxy now refuses a statement when the encryption service returns fewer values than it was given, instead of sending the unmatched parameters unencrypted. See [Encryption result count mismatch](docs/errors.md#encrypt-result-count-mismatch).
- **Updated PostgreSQL client library**: Proxy is now built against `tokio-postgres` 0.7.18, which resolves [GHSA-3gjw-f78c-vvpw](https://github.com/advisories/GHSA-3gjw-f78c-vvpw). No configuration change is required.
diff --git a/docs/errors.md b/docs/errors.md
index 02a91c87..b7c6023f 100644
--- a/docs/errors.md
+++ b/docs/errors.md
@@ -17,6 +17,7 @@
- [Prepared settings changed](#mapping-prepared-settings-changed)
- [Unmodelled DDL](#mapping-unmodelled-ddl)
- [Unmappable encrypted column](#mapping-unmappable-encrypted-column)
+ - [Unresolvable table](#mapping-unresolvable-table)
- [Unknown prepared statement](#mapping-unknown-prepared-statement)
- [Internal Error](#mapping-internal-error)
@@ -404,6 +405,45 @@ If the column is no longer used, dropping it also resolves the error.
+
+
+
+## Unresolvable table
+
+A statement that writes data refers to a table that is not in the database schema CipherStash Proxy has loaded.
+Proxy cannot tell whether the statement writes to an encrypted column, so it refuses the statement rather than forward its values unencrypted.
+
+### Error message
+
+```
+Statement refers to table 'users', which is not in the database schema Proxy has loaded, so Proxy cannot tell whether it writes to an encrypted column.
+```
+
+### Notes
+
+The refusal applies to `INSERT`, `UPDATE`, `DELETE` and `MERGE`, including one inside `PREPARE`, `EXPLAIN` or a `WITH` clause, when any column is encrypted.
+It applies wherever the statement names the table, so `INSERT INTO users ... SELECT ... FROM other` is refused when `other` cannot be resolved.
+Read-only statements are not refused.
+When no columns are encrypted, Proxy forwards these statements.
+
+Unlike a type-check failure, this refusal is not subject to the `CS_DEVELOPMENT__ENABLE_MAPPING_ERRORS` passthrough.
+
+Proxy loads the tables in the schemas on its database connection's search path.
+A schema-qualified name such as `public.users` resolves only to a table loaded from that schema.
+A bare table name used in more than one schema on the search path cannot be resolved, because the client's search path can differ from Proxy's.
+Encryption configuration is keyed by the unqualified table name, so when same-named tables in different schemas declare a column with different EQL domains, Proxy loads no configuration for that column and refuses statements on it.
+When they declare the same domain, both tables share one configuration and one key descriptor, so they are not cryptographically separate: a ciphertext copied from one table to the other still decrypts, and equal values produce equal search terms in both.
+A table in a schema that is not on the search path, or one created on another connection since Proxy last loaded the schema, cannot be resolved.
+
+### How to fix
+
+Check that the table exists and that its name is spelled as PostgreSQL resolves it.
+If the table name is used in more than one schema, qualify it with its schema, as in `public.users`.
+If the table is in a schema outside the search path, add that schema to the search path of the database user Proxy connects as, then restart Proxy or wait for it to reload the schema.
+If the table was created on another connection, retry once Proxy has reloaded the database schema.
+
+
+
diff --git a/packages/cipherstash-proxy/CONTEXT.md b/packages/cipherstash-proxy/CONTEXT.md
index ac7b1649..2f678bf6 100644
--- a/packages/cipherstash-proxy/CONTEXT.md
+++ b/packages/cipherstash-proxy/CONTEXT.md
@@ -54,9 +54,12 @@ _Avoid_: session (`start_session`, `SessionId` and the
`..._statements_session_duration_seconds` metric all use this sense and are misnamed).
**Passthrough**:
-Traffic forwarded without encryption or rewriting. Three independent conditions produce
+Traffic forwarded without encryption or rewriting. Four independent conditions produce
it and they are not interchangeable: no encrypt config is loaded, mapping is disabled by
-configuration, or `SET CIPHERSTASH.UNSAFE_DISABLE_MAPPING` was issued on this connection.
+configuration, `SET CIPHERSTASH.UNSAFE_DISABLE_MAPPING` was issued on this connection, or
+the **type-check fallback** forwarded a statement EQL Mapper could not type check while
+mapping errors are off. The fallback never applies to a refusal, and never to a simple
+Query with another statement that needs mapping.
Name the condition rather than saying "passthrough" alone.
### Encryption
diff --git a/packages/cipherstash-proxy/src/error.rs b/packages/cipherstash-proxy/src/error.rs
index 83463623..532cbc5b 100644
--- a/packages/cipherstash-proxy/src/error.rs
+++ b/packages/cipherstash-proxy/src/error.rs
@@ -73,9 +73,11 @@ impl Error {
self,
// Forwarding a statement that references a legacy EQL v2 column
// stores plaintext in a column its operator believes is encrypted
- // (CIP-3688). No configuration may turn that back on.
+ // (CIP-3688). No configuration may turn that back on. Nor may a
+ // write to a table Proxy cannot resolve be forwarded.
Error::Mapping(
MappingError::UnmappableEncryptedColumn { .. }
+ | MappingError::UnresolvableTable { .. }
| MappingError::DependentStatementAfterDdl
| MappingError::DependentStatementAfterSettings
| MappingError::PreparedSettingsChanged
@@ -153,6 +155,9 @@ pub enum MappingError {
#[error("Statement could not be type checked: {}. For help visit {}#mapping-statement-could-not-be-type-checked", _0, ERROR_DOC_BASE_URL)]
StatementCouldNotBeTypeChecked(String),
+ #[error("Statement refers to table '{table}', which is not in the database schema Proxy has loaded, so Proxy cannot tell whether it writes to an encrypted column. For help visit {}#mapping-unresolvable-table", ERROR_DOC_BASE_URL)]
+ UnresolvableTable { table: String },
+
#[error("Column '{table}.{column}' is declared as '{column_type}', a legacy EQL v2 type that this version of CipherStash Proxy cannot encrypt or decrypt. Statements referencing '{table}' are refused so that plaintext is never written to the column. Migrate '{table}.{column}' to an EQL v3 domain type. For help visit {}#mapping-unmappable-encrypted-column", ERROR_DOC_BASE_URL)]
UnmappableEncryptedColumn {
table: String,
diff --git a/packages/cipherstash-proxy/src/postgresql/context/mod.rs b/packages/cipherstash-proxy/src/postgresql/context/mod.rs
index a418ad83..c41a00e6 100644
--- a/packages/cipherstash-proxy/src/postgresql/context/mod.rs
+++ b/packages/cipherstash-proxy/src/postgresql/context/mod.rs
@@ -1732,6 +1732,30 @@ where
Ok(())
}
+ /// Records the native tables a simple-query batch creates, so the batch's
+ /// later statements can write to them.
+ pub fn note_native_tables(&self, statements: &[sqltk::parser::ast::Statement]) {
+ self.schema_middleware.note_native_tables(statements);
+ }
+
+ /// Whether every table `statement` writes to is a native table this
+ /// connection created that the schema may not model yet.
+ pub fn writes_only_native_tables(&self, statement: &sqltk::parser::ast::Statement) -> bool {
+ eql_mapper::write_targets(statement).is_some_and(|targets| {
+ !targets.is_empty()
+ && targets
+ .iter()
+ .all(|target| self.schema_middleware.is_native_table(target))
+ })
+ }
+
+ /// Whether any column is encrypted. A column can be encrypted without
+ /// encrypt config, when same-named tables disagree on its domain.
+ pub fn has_encrypted_columns(&self) -> bool {
+ !self.schema_middleware.encrypt_config().is_empty()
+ || self.schema_middleware.schema_has_encrypted_columns()
+ }
+
pub fn is_passthrough(&self) -> bool {
self.schema_middleware.encrypt_config().is_empty() || self.config.mapping_disabled()
}
diff --git a/packages/cipherstash-proxy/src/postgresql/middleware/frontend.rs b/packages/cipherstash-proxy/src/postgresql/middleware/frontend.rs
index da2dcf7c..88933e44 100644
--- a/packages/cipherstash-proxy/src/postgresql/middleware/frontend.rs
+++ b/packages/cipherstash-proxy/src/postgresql/middleware/frontend.rs
@@ -369,6 +369,9 @@ impl Frontend {
{
return Err(MappingError::DependentStatementAfterDdl.into());
}
+ // So a write later in this batch to a native table it creates is not
+ // refused as a write to an unresolvable table.
+ self.context.note_native_tables(&parsed_statements);
if parsed_statements
.iter()
.any(eql_mapper::requires_type_check)
@@ -385,9 +388,10 @@ impl Frontend {
let mut portal = Portal::passthrough(Some(session_id));
let mut encrypted = false;
+ let mut mapped = false;
let mut parse_duration_recorded = false;
- for statement in &parsed_statements {
+ for (index, statement) in parsed_statements.iter().enumerate() {
if self.context.unsafe_disable_mapping()? {
warn!(msg = "Encrypted statement mapping is not enabled");
counter!(STATEMENTS_PASSTHROUGH_MAPPING_DISABLED_TOTAL).increment(1);
@@ -412,6 +416,13 @@ impl Frontend {
// fallback altogether.
if self.context.mapping_errors_enabled() || err.must_fail_closed() {
return Err(err);
+ }
+ // The fallback forwards the whole Query as the client wrote
+ // it, so it must not carry another statement that needs
+ // mapping: that statement's values would reach the database
+ // unencrypted.
+ if mapped || self.any_requires_mapping(&parsed_statements[index + 1..])? {
+ return Err(err);
} else {
self.context.register_query(
operation,
@@ -430,6 +441,7 @@ impl Frontend {
client_id = self.context.client_id,
msg = "Encryptable Statement",
);
+ mapped = true;
let mut transformed = false;
if typed_statement.requires_transform() {
@@ -1216,6 +1228,30 @@ impl Frontend {
Ok(())
}
+ /// Whether any of `statements` touches an encrypted column. A statement
+ /// that must be refused returns its refusal; one that fails type checking
+ /// for another reason counts as not touching one.
+ fn any_requires_mapping(&self, statements: &[ast::Statement]) -> Result {
+ for statement in statements {
+ if !eql_mapper::requires_type_check(statement) {
+ continue;
+ }
+ match self.type_check(statement) {
+ Ok(typed_statement) => {
+ if self
+ .to_encryptable_statement(&typed_statement, vec![])?
+ .is_some()
+ {
+ return Ok(true);
+ }
+ }
+ Err(err) if err.must_fail_closed() => return Err(err),
+ Err(_) => {}
+ }
+ }
+ Ok(false)
+ }
+
fn type_check<'a>(
&self,
statement: &'a ast::Statement,
@@ -1249,6 +1285,26 @@ impl Frontend {
}
.into())
}
+ // The type checker never saw the table's columns, so a write to it
+ // could put plaintext in an encrypted column.
+ Err(err)
+ if err.as_table_not_found().is_some()
+ && eql_mapper::writes_data(statement)
+ && self.context.has_encrypted_columns()
+ && !self.context.writes_only_native_tables(statement) =>
+ {
+ let table = err.as_table_not_found().unwrap();
+ warn!(
+ client_id = self.context.client_id,
+ msg = "Statement refused: it writes data and refers to a table that is not in the loaded database schema, so Proxy cannot tell whether it writes to an encrypted column.",
+ table,
+ );
+ counter!(STATEMENTS_UNMAPPABLE_TOTAL).increment(1);
+ Err(MappingError::UnresolvableTable {
+ table: table.to_string(),
+ }
+ .into())
+ }
Err(EqlMapperError::InternalError(str)) => {
warn!(
client_id = self.context.client_id,
@@ -3570,3 +3626,485 @@ mod settings_reset_tests {
assert_eq!(frontend.context.keyset_identifier().unwrap(), None);
}
}
+
+/// A statement Proxy cannot type check is forwarded as written when
+/// mapping errors are off. These pin when that fallback is refused instead.
+#[cfg(test)]
+mod unresolvable_table_tests {
+ use super::Frontend;
+ use crate::config::TandemConfig;
+ use crate::error::{Error, MappingError};
+ use crate::postgresql::context::{Context, KeysetIdentifier};
+ use crate::postgresql::test_operation_id as operation_id;
+ use crate::postgresql::Column;
+ use crate::proxy::{EncryptConfig, EncryptionService};
+ use bytes::Bytes;
+ use cipherstash_client::encryption::Plaintext;
+ use cipherstash_client::eql::{EncryptedPayloadV3, EQL_SCHEMA_VERSION_V3};
+ use cipherstash_client::zerokms::EncryptedRecord;
+ use pg_proto::{FrontendMessage, FrontendMiddlewareOutput, Parse};
+ use std::sync::{
+ atomic::{AtomicUsize, Ordering},
+ Arc,
+ };
+
+ /// Encrypts every value as a stub ciphertext and counts the values.
+ #[derive(Clone, Default)]
+ struct Counter {
+ encrypted: Arc,
+ }
+
+ #[async_trait::async_trait]
+ impl EncryptionService for Counter {
+ async fn encrypt(
+ &self,
+ _: Option,
+ plaintexts: Vec