Skip to content

Commit 960427a

Browse files
committed
Make guarded relation queries executable
Expose a bounded relation-atom query path so stored facts can be read through the same guarded daemon, client, and CLI boundary that ingests them. Constraint: Public surfaces must not expose storage paths, internal relation names, arbitrary programs, or tracker metadata. Rejected: General Datalog program upload | unsafe without evaluator budgets and reviewable rule packaging. Rejected: Direct fact-store reads in wyctl | bypasses daemon authorization and audit boundaries. Confidence: medium Scope-risk: moderate Directive: Keep query input as a parsed allowlisted atom until cancellation and recursion budgets exist. Tested: full fact-enabled test suite; full default test suite Not-tested: Audit-enabled fact query build; current local fact build has audit disabled.
1 parent 5f4db57 commit 960427a

15 files changed

Lines changed: 1325 additions & 2 deletions

tests/test-daemon-http-facts.c

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ grant_fact_http_authority (WylHandle *handle, const gchar *subject)
133133
"wr.graph.manage",
134134
"wr.schema.manage",
135135
"wr.fact.write",
136+
"wr.datalog.query",
136137
};
137138
wyl_policy_store_t *store = wyl_handle_get_policy_store (handle);
138139
for (gsize i = 0; i < G_N_ELEMENTS (perms); i++) {
@@ -352,6 +353,107 @@ check_fact_http_contract (WylHandle *handle, const gchar *base_url)
352353
if (rc != 0)
353354
return rc;
354355

356+
g_clear_pointer (&body, g_free);
357+
g_autofree gchar *datalog_query = g_strdup_printf ("tenant=%s&%s",
358+
WYL_TENANT_DEFAULT, FACT_GUARD);
359+
rc = send_raw (session, "POST", base_url,
360+
"/datalog/__wr_default/orders/query", datalog_query, NULL,
361+
"{\"query\":\"orders(O,A)\",\"output\":\"json\",\"limit\":10}",
362+
&status, &body);
363+
if (rc != 0)
364+
return rc;
365+
if (status != 401 || strstr (body, "\"datalog_auth_required\"") == NULL)
366+
return 330;
367+
368+
g_clear_pointer (&body, g_free);
369+
rc = send_raw (session, "POST", base_url,
370+
"/datalog/__wr_default/orders/query", datalog_query, deny_token,
371+
"{\"query\":\"orders(O,A)\",\"output\":\"json\",\"limit\":10}",
372+
&status, &body);
373+
if (rc != 0)
374+
return rc;
375+
if (status != 403 || strstr (body, "\"datalog_denied\"") == NULL)
376+
return 331;
377+
378+
const gchar *invalid_datalog_bodies[] = {
379+
"{\"query\":\"orders(O,A) :- orders(O,A)\",\"output\":\"json\"}",
380+
"{\"query\":\".decl orders(O:symbol,A:int64)\",\"output\":\"json\"}",
381+
"{\"query\":\"orders(O,A);orders(O,A)\",\"output\":\"json\"}",
382+
"{\"query\":\"SELECT * FROM orders\",\"output\":\"json\"}",
383+
};
384+
for (gsize i = 0; i < G_N_ELEMENTS (invalid_datalog_bodies); i++) {
385+
g_clear_pointer (&body, g_free);
386+
rc = send_raw (session, "POST", base_url,
387+
"/datalog/__wr_default/orders/query", datalog_query, admin_token,
388+
invalid_datalog_bodies[i], &status, &body);
389+
if (rc != 0)
390+
return rc;
391+
if (status != 400 || strstr (body, "\"invalid_datalog_request\"") == NULL)
392+
return 340 + (gint) i;
393+
}
394+
395+
g_clear_pointer (&body, g_free);
396+
rc = send_raw (session, "POST", base_url,
397+
"/datalog/__wr_default/orders/query", datalog_query, admin_token,
398+
"{\"query\":\"orders(O,A)\",\"output\":\"json\",\"limit\":10}",
399+
&status, &body);
400+
if (rc != 0)
401+
return rc;
402+
if (status != 200 || strstr (body, "\"relation\":\"orders\"") == NULL ||
403+
strstr (body, "\"columns\":[\"O\",\"A\"]") == NULL ||
404+
strstr (body, "{\"O\":\"o-1\",\"A\":42}") == NULL ||
405+
strstr (body, "facts.duckdb") != NULL)
406+
return 332;
407+
408+
g_clear_pointer (&body, g_free);
409+
rc = send_raw (session, "POST", base_url,
410+
"/datalog/__wr_default/orders/query", datalog_query, admin_token,
411+
"{\"query\":\"payments(P)\",\"output\":\"json\",\"limit\":10}",
412+
&status, &body);
413+
if (rc != 0)
414+
return rc;
415+
if (status != 403 || strstr (body, "\"datalog_relation_denied\"") == NULL)
416+
return 333;
417+
418+
if (wyl_handle_replay_fact_graphs (handle, NULL) != WYRELOG_E_OK)
419+
return 334;
420+
g_clear_pointer (&body, g_free);
421+
rc = send_raw (session, "POST", base_url,
422+
"/datalog/__wr_default/orders/query", datalog_query, admin_token,
423+
"{\"query\":\"orders(\\\"o-1\\\",A)\",\"output\":\"json\",\"limit\":10}",
424+
&status, &body);
425+
if (rc != 0)
426+
return rc;
427+
if (status != 200 || strstr (body, "{\"A\":42}") == NULL ||
428+
strstr (body, "\"row_count\":1") == NULL)
429+
return 335;
430+
431+
g_clear_pointer (&body, g_free);
432+
g_autofree gchar *append_query_2 = g_strdup_printf
433+
("tenant=%s&namespace=shop&schema_version=1&batch_id=batch-7&"
434+
"idempotency_key=key-7&%s", WYL_TENANT_DEFAULT, FACT_GUARD);
435+
rc = send_raw (session, "POST", base_url,
436+
"/facts/__wr_default/orders/orders:append", append_query_2,
437+
admin_token, "order_id\tamount\no-2\t84\n", &status, &body);
438+
if (rc != 0)
439+
return rc;
440+
if (status != 200 || strstr (body, "\"inserted\":true") == NULL)
441+
return 336;
442+
rc = check_fact_projection_row_count (handle, "orders", 2);
443+
if (rc != 0)
444+
return rc;
445+
446+
g_clear_pointer (&body, g_free);
447+
rc = send_raw (session, "POST", base_url,
448+
"/datalog/__wr_default/orders/query", datalog_query, admin_token,
449+
"{\"query\":\"orders(O,A)\",\"output\":\"json\",\"limit\":1}",
450+
&status, &body);
451+
if (rc != 0)
452+
return rc;
453+
if (status != 200 || strstr (body, "\"row_count\":1") == NULL ||
454+
strstr (body, "\"truncated\":true") == NULL)
455+
return 337;
456+
355457
g_clear_pointer (&body, g_free);
356458
g_autofree gchar *bad_append_query = g_strdup_printf
357459
("tenant=%s&namespace=shop&schema_version=1&batch_id=batch-2&"
@@ -363,7 +465,7 @@ check_fact_http_contract (WylHandle *handle, const gchar *base_url)
363465
return rc;
364466
if (status != 400 || strstr (body, "\"invalid_fact_payload\"") == NULL)
365467
return 29;
366-
rc = check_fact_projection_row_count (handle, "orders", 1);
468+
rc = check_fact_projection_row_count (handle, "orders", 2);
367469
if (rc != 0)
368470
return rc;
369471

tests/test-wyctl-policy-daemon.c

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ grant_fact_authority (WylHandle *handle, const gchar *subject)
9393
"wr.graph.manage",
9494
"wr.schema.manage",
9595
"wr.fact.write",
96+
"wr.datalog.query",
9697
};
9798
wyl_policy_store_t *store = wyl_handle_get_policy_store (handle);
9899
for (gsize i = 0; i < G_N_ELEMENTS (perms); i++) {
@@ -271,6 +272,27 @@ assert_wyctl_stdout (gchar **argv, const gchar *expected_stdout)
271272
g_assert_cmpstr (stdout_buf, ==, expected_stdout);
272273
g_assert_cmpstr (stderr_buf, ==, "");
273274
}
275+
276+
static void
277+
assert_wyctl_stdout_contains (gchar **argv, const gchar *needle)
278+
{
279+
g_autofree gchar *stdout_buf = NULL;
280+
g_autofree gchar *stderr_buf = NULL;
281+
gint wait_status = 0;
282+
g_autoptr (GError) error = NULL;
283+
284+
run_wyctl (argv, &stdout_buf, &stderr_buf, &wait_status);
285+
286+
if (!g_spawn_check_wait_status (wait_status, &error)) {
287+
g_printerr ("wyctl exited with status %d\nstdout: %s\nstderr: %s\n",
288+
wait_status, stdout_buf ? stdout_buf : "(null)",
289+
stderr_buf ? stderr_buf : "(null)");
290+
g_clear_error (&error);
291+
g_assert_not_reached ();
292+
}
293+
g_assert_nonnull (strstr (stdout_buf, needle));
294+
g_assert_cmpstr (stderr_buf, ==, "");
295+
}
274296
#endif
275297

276298
int
@@ -506,6 +528,23 @@ main (void)
506528
assert_wyctl_stdout (fact_put_argv, "inserted\n");
507529
if (check_fact_projection_count (handle, 1) != 0)
508530
return 104;
531+
gchar *datalog_query_argv[] = {
532+
(gchar *) WYL_TEST_WYCTL_PATH,
533+
"--daemon-url", (gchar *) base_url,
534+
"datalog", "query",
535+
"--tenant", (gchar *) WYL_TENANT_DEFAULT,
536+
"--graph", "orders",
537+
"--query", "orders(O,A)",
538+
"--output", "json",
539+
"--limit", "10",
540+
"--access-token-file", token_path,
541+
"--guard-timestamp", "123",
542+
"--guard-loc-class", "trusted",
543+
"--guard-risk", "29",
544+
NULL,
545+
};
546+
assert_wyctl_stdout_contains (datalog_query_argv,
547+
"\"rows\":[{\"O\":\"o-1\",\"A\":42}]");
509548
assert_wyctl_stdout (fact_put_argv, "duplicate\n");
510549
if (check_fact_projection_count (handle, 1) != 0)
511550
return 105;

wyrelog/client.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,13 @@ wyrelog_error_t wyl_client_fact_put_batch (WylClient * client,
169169
gint64 guard_timestamp,
170170
const gchar * guard_loc_class,
171171
gint64 guard_risk, WylClientFactAppendResult ** out_result);
172+
wyrelog_error_t wyl_client_datalog_query_json (WylClient * client,
173+
const gchar * tenant,
174+
const gchar * graph,
175+
const gchar * query,
176+
guint limit,
177+
gint64 guard_timestamp,
178+
const gchar * guard_loc_class, gint64 guard_risk, gchar ** out_json);
172179
void wyl_client_fact_append_result_free (WylClientFactAppendResult * result);
173180
gboolean wyl_client_fact_append_result_get_inserted
174181
(const WylClientFactAppendResult * result);

0 commit comments

Comments
 (0)