Skip to content

Commit 27b6ded

Browse files
committed
fix(cache): exclude secret-dependent results
Qualify worker-owned dependencies in catalog macro ASTs so macros remain valid under caller-selected catalog aliases. Fail closed on producer and exchange result caching whenever a function declares required secrets, without retaining secret material in keys or diagnostics.
1 parent 474dd3b commit 27b6ded

7 files changed

Lines changed: 208 additions & 7 deletions

File tree

‎src/include/vgi_exchange_cache_key.hpp‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ struct VgiCachedBatch; // vgi_result_cache.hpp
2626
struct VgiCacheControl; // vgi_cache_control.hpp
2727
struct VgiTableInOutBindData; // vgi_table_in_out_impl.hpp
2828
struct VgiAttachParameters; // vgi_attach_parameters.hpp
29+
struct VgiSecretRequirement; // vgi_catalog_metadata.hpp
2930

3031
// ============================================================================
3132
// Canonical key-component serializers (shared with the producer path)
@@ -105,7 +106,8 @@ void SyncResultCacheSettings(ClientContext &context);
105106
//! producer-only filter/order/sample components). `key.input_hash` is left EMPTY —
106107
//! the caller sets it per memoization event. Returns false + sets `*reason` when
107108
//! ineligible (global switch off, per-catalog opt-out, unresolved identity,
108-
//! unknown catalog version). On true, fills `key`, `catalog_name`, `catalog_version`.
109+
//! unknown catalog version, or any secret dependency). On true, fills `key`,
110+
//! `catalog_name`, `catalog_version`.
109111
//!
110112
//! SECURITY: identity_scope folds in the caller's auth principal
111113
//! (BuildCatalogIdentityScope); "" fails closed so two identities never cross-serve.
@@ -126,7 +128,9 @@ bool BuildExchangeCacheKeyStaticFields(ClientContext &context,
126128
const std::string &function_name, const std::string &schema_name,
127129
const std::string &canonical_arguments,
128130
const std::map<std::string, Value> &settings,
129-
const std::vector<int32_t> &projection_ids, VgiResultCacheKey &key,
131+
const std::vector<int32_t> &projection_ids,
132+
const std::vector<VgiSecretRequirement> &required_secrets,
133+
VgiResultCacheKey &key,
130134
std::string &catalog_name, int64_t &catalog_version,
131135
const char *&reason, const std::string &operator_kind);
132136

‎src/storage/vgi_macro_set.cpp‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,17 @@
44
#include "duckdb/catalog/catalog.hpp"
55
#include "duckdb/catalog/catalog_entry/scalar_macro_catalog_entry.hpp"
66
#include "duckdb/catalog/catalog_entry/table_macro_catalog_entry.hpp"
7+
#include "duckdb/common/case_insensitive_map.hpp"
78
#include "duckdb/function/scalar_macro_function.hpp"
89
#include "duckdb/function/table_macro_function.hpp"
910
#include "duckdb/main/attached_database.hpp"
1011
#include "duckdb/main/client_context.hpp"
1112
#include "duckdb/parser/expression/columnref_expression.hpp"
1213
#include "duckdb/parser/expression/constant_expression.hpp"
14+
#include "duckdb/parser/expression/function_expression.hpp"
15+
#include "duckdb/parser/expression/subquery_expression.hpp"
1316
#include "duckdb/parser/parsed_data/create_macro_info.hpp"
17+
#include "duckdb/parser/parsed_expression_iterator.hpp"
1418
#include "duckdb/parser/parser.hpp"
1519
#include "duckdb/parser/statement/select_statement.hpp"
1620

@@ -23,6 +27,81 @@
2327

2428
namespace duckdb {
2529

30+
static void QualifyWorkerFunctions(QueryNode &node, const case_insensitive_set_t &worker_functions,
31+
const string &catalog_name, const string &schema_name);
32+
33+
static void QualifyWorkerFunctions(ParsedExpression &expression,
34+
const case_insensitive_set_t &worker_functions,
35+
const string &catalog_name, const string &schema_name) {
36+
if (expression.GetExpressionClass() == ExpressionClass::FUNCTION) {
37+
auto &function = expression.Cast<FunctionExpression>();
38+
if (function.catalog.empty() && function.schema.empty() &&
39+
worker_functions.find(function.function_name) != worker_functions.end()) {
40+
function.catalog = catalog_name;
41+
function.schema = schema_name;
42+
}
43+
} else if (expression.GetExpressionClass() == ExpressionClass::SUBQUERY) {
44+
auto &subquery = expression.Cast<SubqueryExpression>();
45+
QualifyWorkerFunctions(*subquery.subquery->node, worker_functions, catalog_name, schema_name);
46+
}
47+
48+
ParsedExpressionIterator::EnumerateChildren(expression, [&](ParsedExpression &child) {
49+
QualifyWorkerFunctions(child, worker_functions, catalog_name, schema_name);
50+
});
51+
}
52+
53+
static void QualifyWorkerFunctions(QueryNode &node, const case_insensitive_set_t &worker_functions,
54+
const string &catalog_name, const string &schema_name) {
55+
ParsedExpressionIterator::EnumerateQueryNodeChildren(
56+
node, [&](unique_ptr<ParsedExpression> &expression) {
57+
QualifyWorkerFunctions(*expression, worker_functions, catalog_name, schema_name);
58+
});
59+
}
60+
61+
static void AddFunctionNames(const vgi::CatalogRpcContext &rpc_ctx, const string &schema_name,
62+
const char *function_type, ClientContext &context,
63+
case_insensitive_set_t &worker_functions) {
64+
for (const auto &function :
65+
vgi::InvokeCatalogSchemaContentsFunctions(rpc_ctx, schema_name, function_type, context)) {
66+
worker_functions.insert(function.name);
67+
}
68+
}
69+
70+
static case_insensitive_set_t LoadWorkerFunctionNames(const vgi::CatalogRpcContext &rpc_ctx,
71+
const VgiSchemaEntry &schema,
72+
const std::vector<vgi::VgiMacroInfo> &macros,
73+
CatalogType macro_catalog_type,
74+
bool trust_empty_kinds,
75+
ClientContext &context) {
76+
case_insensitive_set_t worker_functions;
77+
for (const auto &macro : macros) {
78+
worker_functions.insert(macro.name);
79+
}
80+
81+
const auto &counts = schema.GetEstimatedCounts();
82+
if (!trust_empty_kinds || counts.scalar_function != 0) {
83+
AddFunctionNames(rpc_ctx, schema.name, "SCALAR_FUNCTION", context, worker_functions);
84+
}
85+
if (!trust_empty_kinds || counts.aggregate_function != 0) {
86+
AddFunctionNames(rpc_ctx, schema.name, "AGGREGATE_FUNCTION", context, worker_functions);
87+
}
88+
if (!trust_empty_kinds || counts.table_function != 0) {
89+
AddFunctionNames(rpc_ctx, schema.name, "TABLE_FUNCTION", context, worker_functions);
90+
}
91+
92+
// Table macros can use scalar macros in their SELECT expressions. Scalar
93+
// macros cannot call table macros, and macros of the current kind are already
94+
// present in `macros`, so this is the only additional macro inventory needed.
95+
if (macro_catalog_type == CatalogType::TABLE_MACRO_ENTRY &&
96+
(!trust_empty_kinds || counts.macro != 0)) {
97+
for (const auto &macro :
98+
vgi::InvokeCatalogSchemaContentsMacros(rpc_ctx, schema.name, "SCALAR_MACRO", context)) {
99+
worker_functions.insert(macro.name);
100+
}
101+
}
102+
return worker_functions;
103+
}
104+
26105
VgiMacroSet::VgiMacroSet(Catalog &catalog, VgiSchemaEntry &schema, CatalogType macro_type)
27106
: VgiCatalogSet(catalog, &schema), schema_(schema), macro_catalog_type_(macro_type) {
28107
}
@@ -86,6 +165,8 @@ void VgiMacroSet::LoadEntries(ClientContext &context, const std::lock_guard<std:
86165
rpc_ctx.entity_qualifier = schema_.name;
87166

88167
auto macros = vgi::InvokeCatalogSchemaContentsMacros(rpc_ctx, schema_.name, rpc_type, context);
168+
auto worker_functions = LoadWorkerFunctionNames(rpc_ctx, schema_, macros, macro_catalog_type_,
169+
ReadTrustEmptyKinds(context), context);
89170

90171
for (auto &macro_info : macros) {
91172
try {
@@ -99,6 +180,7 @@ void VgiMacroSet::LoadEntries(ClientContext &context, const std::lock_guard<std:
99180
macro_info.name.c_str());
100181
continue;
101182
}
183+
QualifyWorkerFunctions(*expressions[0], worker_functions, catalog_.GetName(), schema_.name);
102184
macro_func = make_uniq<ScalarMacroFunction>(std::move(expressions[0]));
103185
} else {
104186
// Table macro: parse query
@@ -111,6 +193,7 @@ void VgiMacroSet::LoadEntries(ClientContext &context, const std::lock_guard<std:
111193
continue;
112194
}
113195
auto &select = parser.statements[0]->Cast<SelectStatement>();
196+
QualifyWorkerFunctions(*select.node, worker_functions, catalog_.GetName(), schema_.name);
114197
macro_func = make_uniq<TableMacroFunction>(std::move(select.node));
115198
}
116199

‎src/vgi_exchange_cache_key.cpp‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,9 @@ bool BuildExchangeCacheKeyStaticFields(ClientContext &context,
272272
const std::string &function_name, const std::string &schema_name,
273273
const std::string &canonical_arguments,
274274
const std::map<std::string, Value> &settings,
275-
const std::vector<int32_t> &projection_ids, VgiResultCacheKey &key,
275+
const std::vector<int32_t> &projection_ids,
276+
const std::vector<VgiSecretRequirement> &required_secrets,
277+
VgiResultCacheKey &key,
276278
std::string &catalog_name, int64_t &catalog_version,
277279
const char *&reason, const std::string &operator_kind) {
278280
reason = nullptr;
@@ -292,6 +294,15 @@ bool BuildExchangeCacheKeyStaticFields(ClientContext &context,
292294
reason = "disabled_attach";
293295
return false;
294296
}
297+
// Resolved secret values are deliberately absent from cache identity and
298+
// diagnostics. A function that requested any secret can therefore neither
299+
// safely probe nor populate a result cache: rotation/removal must be visible
300+
// on the very next call, and hashing secret material into a key would retain
301+
// credentials in process/disk state. Fail closed for every exchange shape.
302+
if (!required_secrets.empty()) {
303+
reason = "secret_dependent";
304+
return false;
305+
}
295306

296307
// Identity + version. Catalog-attached functions resolve a VgiCatalog by name so
297308
// the runtime catalog_version gates freshness (0 = unknown → never cache; a bump
@@ -352,8 +363,8 @@ bool BuildExchangeCacheKeyStatic(ClientContext &context, const VgiTableInOutBind
352363
const std::string &operator_kind) {
353364
return BuildExchangeCacheKeyStaticFields(context, bd.attach_params, bd.function_name, bd.schema_name,
354365
bd.arguments.array ? bd.arguments.array->ToString() : "",
355-
bd.settings, projection_ids, key, catalog_name, catalog_version,
356-
reason, operator_kind);
366+
bd.settings, projection_ids, bd.required_secrets, key, catalog_name,
367+
catalog_version, reason, operator_kind);
357368
}
358369

359370
// ----------------------------------------------------------------------------

‎src/vgi_scalar_function_impl.cpp‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -573,14 +573,17 @@ void VgiScalarFunctionExecute(DataChunk &args, ExpressionState &state, Vector &r
573573
const std::string op_kind = "scalar\x1f" + result.GetType().ToString();
574574
if (BuildExchangeCacheKeyStaticFields(context, bind_data->attach_params, bind_data->function_name,
575575
bind_data->schema_name, canon_args, bind_data->settings, {},
576-
local_state.cache_static_key, local_state.cache_catalog_name, cver,
577-
reason, op_kind)) {
576+
bind_data->required_secrets, local_state.cache_static_key,
577+
local_state.cache_catalog_name, cver, reason, op_kind)) {
578578
local_state.cache_eligible = true;
579579
local_state.cache_static_fp = local_state.cache_static_key.Fingerprint();
580580
Value ttl_v;
581581
if (context.TryGetCurrentSetting("vgi_result_cache_default_ttl_seconds", ttl_v) && !ttl_v.IsNull()) {
582582
local_state.cache_default_ttl_seconds = static_cast<int64_t>(ttl_v.GetValue<uint64_t>());
583583
}
584+
} else if (reason) {
585+
VGI_LOG(context, "result_cache.ineligible",
586+
{{"function", bind_data->function_name}, {"reason", reason}});
584587
}
585588
}
586589
bool pv_setting = true;

‎src/vgi_table_function_impl.cpp‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1464,6 +1464,13 @@ CacheEligibility EvaluateCacheEligibility(ClientContext &context,
14641464
e.ineligible_reason = "disabled_attach";
14651465
return e;
14661466
}
1467+
// Secret values are intentionally neither serialized into the key nor retained
1468+
// by the cache. Any declared secret dependency makes the result sensitive to
1469+
// rotation/removal, so probing or storing it would serve stale credentials.
1470+
if (!bind_data.required_secrets.empty()) {
1471+
e.ineligible_reason = "secret_dependent";
1472+
return e;
1473+
}
14671474
// Identity + version dimensions. Two paths (M5 adds the direct one):
14681475
// - catalog-attached (bind_data.table_entry set): identity = catalog name,
14691476
// freshness bounded by the runtime catalog_version (0 = unknown → never
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# name: test/sql/integration/cache/secret_ineligible.test
2+
# description: Secret-dependent producer and scalar functions never probe or populate the result cache
3+
# group: [vgi_integration_cache]
4+
5+
require-env VGI_TEST_WORKER
6+
7+
require vgi
8+
9+
require httpfs
10+
11+
statement ok
12+
CALL enable_logging(level := 'debug');
13+
14+
statement ok
15+
ATTACH 'example' AS ex (TYPE vgi, LOCATION '${VGI_TEST_WORKER}');
16+
17+
statement ok
18+
SELECT * FROM vgi_result_cache_flush();
19+
20+
statement ok
21+
CREATE SECRET vgi_cache_ineligible_secret (TYPE vgi_example, secret_string 'before', api_key 'key-1', port 1001, use_ssl true, timeout 1.0);
22+
23+
# Scalar results must reflect rotation even when the worker advertises per-value
24+
# cacheability. The ineligibility event contains only the reason, never secret data.
25+
query I
26+
SELECT ex.return_secret_value() LIKE '%before%';
27+
----
28+
true
29+
30+
statement ok
31+
DROP SECRET vgi_cache_ineligible_secret;
32+
33+
statement ok
34+
CREATE SECRET vgi_cache_ineligible_secret (TYPE vgi_example, secret_string 'after', api_key 'key-2', port 1002, use_ssl false, timeout 2.0);
35+
36+
query I
37+
SELECT ex.return_secret_value() LIKE '%after%';
38+
----
39+
true
40+
41+
# Per-value diagnostics intentionally aggregate by arena rather than exposing the
42+
# function name. A flushed process with only these scalar calls must have no arena.
43+
query I
44+
SELECT COUNT(*) FROM vgi_result_cache() WHERE tier = 'arena';
45+
----
46+
0
47+
48+
# Producer results likewise reflect secret removal instead of replaying the prior
49+
# non-empty result. No producer cache entry is published.
50+
query I
51+
SELECT COUNT(*) > 0 FROM ex.secret_demo();
52+
----
53+
true
54+
55+
statement ok
56+
DROP SECRET vgi_cache_ineligible_secret;
57+
58+
query I
59+
SELECT COUNT(*) FROM ex.secret_demo();
60+
----
61+
0
62+
63+
query I
64+
SELECT COUNT(*) FROM vgi_result_cache() WHERE function = 'secret_demo';
65+
----
66+
0
67+
68+
query I
69+
SELECT COUNT(*) >= 3 FROM duckdb_logs()
70+
WHERE type = 'VGI'
71+
AND message LIKE '%result_cache.ineligible%'
72+
AND message LIKE '%reason=secret_dependent%';
73+
----
74+
true
75+
76+
statement ok
77+
DETACH ex;
78+
79+
statement ok
80+
SELECT * FROM vgi_result_cache_flush();

‎test/sql/integration/macro/macros.test‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,16 @@ SELECT MAX(range) FROM example.vgi_range_table(5);
132132

133133
statement ok
134134
DETACH example;
135+
136+
# Macro dependencies are qualified to the catalog alias chosen by the caller,
137+
# rather than the worker's catalog name or the current search path.
138+
statement ok
139+
ATTACH 'example' AS renamed (TYPE vgi, LOCATION '${VGI_TEST_WORKER}');
140+
141+
query I
142+
SELECT renamed.vgi_multiply(3, 4);
143+
----
144+
12
145+
146+
statement ok
147+
DETACH renamed;

0 commit comments

Comments
 (0)