Document Access Tutorial
████████╗██╗ ██╗████████╗ ██████╗ ██████╗ ██╗ █████╗ ██╗ ╚══██╔══╝██║ ██║╚══██╔══╝██╔═══██╗██╔══██╗██║██╔══██╗██║ ██║ ██║ ██║ ██║ ██║ ██║██████╔╝██║███████║██║ ██║ ██║ ██║ ██║ ██║ ██║██╔══██╗██║██╔══██║██║ ██║ ╚██████╔╝ ██║ ╚██████╔╝██║ ██║██║██║ ██║███████╗ ╚═╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═╝╚══════╝
This tutorial builds a complete document access control policy from scratch. By the end you will have a policy that enforces ownership, sharing, sensitivity levels, and explicit blocks — and you will understand how each concept connects to the Maelys DL evaluation model.
If you have not read Getting Started yet, start there. This tutorial assumes the library is compiled and linked.
What we are building
A document access system where:
- Users can access documents they own.
- Users can access documents shared with them.
- Users need a sensitivity level of 3 or higher for either path.
- Certain users are explicitly blocked regardless of ownership or sharing.
The final policy will look like this:
blocked("mallory").
allow(User, Doc) :-
owns(User, Doc),
not blocked(User),
sensitivity_level(User, Level),
Level >= 3.
allow(User, Doc) :-
shared_with(User, Doc),
not blocked(User),
sensitivity_level(User, Level),
Level >= 3.Step 1 — Declare the vocabulary
Before loading any policy, declare which predicates exist and what role they play. Every predicate the policy uses must appear in this table.
#include "include/maelys_datalog.h"
static const maelys_datalog_predicate_def_t doc_access_preds[] = {
/* EDB: supplied by the caller at request time */
{ "owns", 2, MAELYS_DATALOG_PRED_KIND_EDB },
{ "shared_with", 2, MAELYS_DATALOG_PRED_KIND_EDB },
{ "sensitivity_level", 2, MAELYS_DATALOG_PRED_KIND_EDB },
/* POLICY_FACT: declared in the .dl source, not supplied at runtime */
{ "blocked", 1, MAELYS_DATALOG_PRED_KIND_POLICY_FACT },
/* IDB + QUERY: derived by the solver, queryable by the caller */
{ "allow", 2, MAELYS_DATALOG_PRED_KIND_IDB |
MAELYS_DATALOG_PRED_KIND_QUERY },
};The four predicate kinds are:
| Kind | Who provides it | Example |
|---|---|---|
EDB | Runtime caller, per request | owns("alice", "doc.pdf") |
POLICY_FACT | Policy source file | blocked("mallory") |
IDB | Solver derives it | allow("alice", "doc.pdf") |
QUERY | Marks IDB as inspectable | — (combined with IDB) |
Step 2 — Write the policy
Save the following as doc_access.dl:
/* mallory is always blocked */
blocked("mallory").
/* allow via ownership */
allow(User, Doc) :-
owns(User, Doc),
not blocked(User),
sensitivity_level(User, Level),
Level >= 3.
/* allow via sharing */
allow(User, Doc) :-
shared_with(User, Doc),
not blocked(User),
sensitivity_level(User, Level),
Level >= 3.Three things to notice:
Two rules, one head. Both rules derive allow/2. The solver produces an
allow fact if either rule's body holds. Having two rules is not redundant —
each represents a different authorization path.
not blocked(User) is non-binding. blocked is evaluated as a filter.
User must already be bound by owns or shared_with before the not
literal is checked. You cannot write not blocked(X) without first binding X
in a positive atom.
Level >= 3 is a comparison filter. Level must be bound by
sensitivity_level(User, Level) before the comparison can fire. Comparisons
never bind variables — they only constrain already-bound values.
Step 3 — Load the policy
For this tutorial we use inline loading: the policy source is in memory and the domain vocabulary is defined as a static C table.
static const char policy_src[] =
"blocked(\"mallory\").\n"
"\n"
"allow(User, Doc) :-\n"
" owns(User, Doc),\n"
" not blocked(User),\n"
" sensitivity_level(User, Level),\n"
" Level >= 3.\n"
"\n"
"allow(User, Doc) :-\n"
" shared_with(User, Doc),\n"
" not blocked(User),\n"
" sensitivity_level(User, Level),\n"
" Level >= 3.\n";
maelys_datalog_policy_set_t ps;
maelys_datalog_diagnostic_t diag = {0};
maelys_result_t rc = maelys_datalog_load_policy_inline_with_static_domain(
doc_access_preds,
sizeof(doc_access_preds) / sizeof(doc_access_preds[0]),
"doc_access", /* domain name */
"doc_access.main", /* policy id */
policy_src,
sizeof(policy_src) - 1,
0,
&ps,
&diag);
if (rc != MAELYS_OK) {
/* diag.message explains the exact failure */
return DENY;
}The policy is now loaded, parsed, and frozen. ps.policies[0] holds the
compiled ruleset. You can call solve_once on it as many times as needed
without reloading — the ruleset never changes between requests.
Step 4 — Evaluate a request
What happens per request
edb_init()Create a fresh fact store for this request. Attach it to the loaded policy's symbol table and registry.
edb_add_fact()Insert the runtime context: who is the user, what do they own, who shared what with them, and what is their sensitivity level.
edb_finalize()Freeze the EDB. No more facts can be added. The solver reads an immutable snapshot.
solve_once()The engine derives all facts that follow from the policy and EDB. It produces an opaque result bundle.
query_solved_ground_fact()Ask whether allow(user, document) is present in the derived state.
solve_result_free()Release the result. Clear the EDB if the same EDB object will be reused for the next request.
Helper function
/* Returns true if allow(user, doc) is derived. */
static bool check_access(
const maelys_datalog_policy_set_t *ps,
const char *user,
const char *doc,
int sensitivity)
{
static maelys_datalog_edb_t edb;
static maelys_datalog_fact_t pool[256];
maelys_datalog_edb_init(&edb, pool, 256,
&ps->policies[0].symbols,
&ps->policies[0].registry);
/* -- owns(user, doc) -- */
maelys_datalog_symbol_id_t uid, did;
maelys_datalog_symbol_intern(
&ps->policies[0].symbols, user, strlen(user), &uid);
maelys_datalog_symbol_intern(
&ps->policies[0].symbols, doc, strlen(doc), &did);
maelys_datalog_term_t pair[2] = {
{ .kind = MAELYS_DATALOG_TERM_SYMBOL, .as.symbol = uid },
{ .kind = MAELYS_DATALOG_TERM_SYMBOL, .as.symbol = did },
};
maelys_datalog_edb_add_fact(&edb, "owns", pair, 2);
/* -- sensitivity_level(user, sensitivity) -- */
maelys_datalog_term_t sl[2] = {
{ .kind = MAELYS_DATALOG_TERM_SYMBOL, .as.symbol = uid },
{ .kind = MAELYS_DATALOG_TERM_INT, .as.integer = sensitivity },
};
maelys_datalog_edb_add_fact(&edb, "sensitivity_level", sl, 2);
maelys_datalog_edb_finalize(&edb);
/* Solve */
maelys_datalog_solve_result_t *result = NULL;
maelys_result_t rc = maelys_datalog_solve_once(
&ps->policies[0], &edb, &result);
if (rc != MAELYS_OK) return false;
bool allowed = false;
maelys_datalog_query_solved_ground_fact(
result, "allow", pair, 2, &allowed);
maelys_datalog_solve_result_free(result);
return allowed;
}Step 5 — Test the policy
int main(void) {
/* ... load policy as in step 3 ... */
/* alice, level 4, owns roadmap.pdf → ALLOW */
printf("alice (level 4, owns): %s\n",
check_access(&ps, "alice", "roadmap.pdf", 4) ? "ALLOW" : "DENY");
/* bob, level 2, owns roadmap.pdf → DENY (level too low) */
printf("bob (level 2, owns): %s\n",
check_access(&ps, "bob", "roadmap.pdf", 2) ? "ALLOW" : "DENY");
/* mallory, level 5, owns roadmap.pdf → DENY (blocked) */
printf("mallory (level 5, owns): %s\n",
check_access(&ps, "mallory", "roadmap.pdf", 5) ? "ALLOW" : "DENY");
return 0;
}Expected output:
alice (level 4, owns): ALLOW
bob (level 2, owns): DENY
mallory (level 5, owns): DENYStep 6 — Add the sharing path
Extend the check to also test the shared_with rule. Update check_access to
optionally insert a shared_with fact:
/* -- shared_with(user, doc) (optional) -- */
maelys_datalog_edb_add_fact(&edb, "shared_with", pair, 2);Then add a test case:
/* carol, level 3, doc shared with her → ALLOW */
printf("carol (level 3, shared): %s\n",
check_access_shared(&ps, "carol", "roadmap.pdf", 3) ? "ALLOW" : "DENY");Expected output:
carol (level 3, shared): ALLOWThe same solver run evaluates both rules. The solver does not short-circuit —
it derives all facts that follow from the policy. If either the owns rule or
the shared_with rule applies, allow is derived.
What you have learned
- Predicates must be declared before a policy can use them.
- EDB facts are supplied per request; POLICY_FACT is in the source.
- not() and comparisons are non-binding filters.
- Multiple rules with the same head represent multiple authorization paths.
- The solver evaluates all rules; query_solved_ground_fact checks the result.
- Failure at any step means DENY.Where to go next
- Registries — more on predicate kinds and domain registration.
- Rulesets — full policy language reference.
- Manifest loading — production-grade loading with SHA verification, multiple policies, and a public query whitelist.
- WASM bindings — run the same policy in a browser.