Skip to content

report policy enforcement state in tracked query meta - #1627

Merged
aaj3f merged 10 commits into
mainfrom
fix/policy-signal-and-docs
Aug 11, 2026
Merged

aaj3f merged 10 commits into
mainfrom
fix/policy-signal-and-docs

Conversation

@aaj3f

@aaj3f aaj3f commented Aug 11, 2026 •

Copy link
Copy Markdown
Contributor

The per-policy tally in tracked query responses only fills in when a policy actually executes, so a request that ran under a policy context granting nothing and a request that ran with no policy context at all both serialized "policy": {} — a caller couldn't distinguish an empty result that policy produced from one the data produced. Tracked responses now also carry policy_enforcement: { enforced, denies_all_data }, recorded from the prepared view before execution. Requests with no policy inputs keep byte-identical output. The same value is exposed as an x-fdb-policy-enforcement response header for CSV/TSV callers, and on the NDJSON streaming endpoint it rides the terminal end record.

Why this is safe to report. The whole design rests on denies_all_data being independent of the data, so it is worth stating the proof rather than the claim. denies_all_data is view_set.restrictions.is_empty() && !default_allow. build_policy_set pushes every action-matching restriction into set.restrictions before consulting stats (fluree-db-policy/src/index.rs:91), and the SchemaHierarchy expansion only extends the targets of restrictions already in the set — it never adds or removes one. So restrictions.is_empty() is decided entirely by which policy nodes the request resolved to, and cannot vary with what is in the ledger's data or with what the query asks for. Thanks to @bplatz for independently checking this — the verification is his.

To be precise about what does participate: the view set is assembled from stored f:AccessPolicy nodes, selected through the identity's own f:policyClass assignments, which are ledger data. The claim is not that no ledger data is involved. It is narrower, and it is the one that matters for an oracle: the value is settled when the view is built, is identical for every query the caller issues under the same policy context, changes only when the policy configuration or the caller's own assignments change, and is visible only to someone already acting as that identity. It reports your own authorization posture and cannot be varied to probe for a row.

Also in here, same neighborhood: the server now parses the fluree-track-policy request header the CLI has always emitted (it previously worked embedded and silently did nothing over HTTP — the round-trip test drives the router with the headers the CLI itself builds, so the seam can't silently rot again), and the policy docs are corrected — three troubleshooting pages documented X-Fluree-Policy-Trace/X-Fluree-Trace headers that have never existed, and the ledger-config pages claimed an unconfigured ledger is fully open, which is false for any identity-carrying request.

Compat. Parsing fluree-track-policy flips the response shape for a request sending only that header, from a bare result array to the tracked envelope. Nobody could have depended on the tracked shape (the header was never honored), but a client that sends it and parses an array will break — including a fluree CLI older than this build against a newer server. Written up in docs/reference/compatibility.md under API Versioning → Behavior changes.

Sequencing notes:

@bplatz bplatz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked the load-bearing security claim and it holds: build_policy_set pushes every action-matching restriction into set.restrictions before consulting stats (index.rs:91), and the SchemaHierarchy expansion only extends existing targets — so restrictions.is_empty() genuinely cannot vary with the data. Worth stating that explicitly in the PR body, since the whole design rests on it.

Inline comments below. One cross-cutting note:

Merge: this and #1625 both rewrite the same doc comment on PolicyDefaults::default_allow (fluree-db-core/src/ledger_config.rs:73-77) — a direct textual conflict. Keep this PR's version when resolving; it's the more informative of the two.

Comment thread docs/security/policy-in-queries.md Outdated

What a caller *can* ask for is whether policy governed the request. Turn on
policy tracking — `"opts": {"meta": {"policy": true}}` in a JSON-LD body, or
the `fluree-track-policy: true` request header on any endpoint (SPARQL bodies

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"on any endpoint" overclaims. /v1/fluree/stream/query/*ledger does enforce policy in its ledger-scoped form, but execute_view_streaming (fluree-db-api/src/view/stream_query.rs:215) never calls record_policy_enforcement, and stream_tracker forces fuel/time on while the end record carries only those two.

So on the endpoint most likely to be used for large policy-filtered result sets, the signal is unavailable — while this same page tells the reader that absent means unenforced. That's a false negative on exactly the question the page exists to answer.

Suggest scoping the sentence to /query + /query/sparql now and filing the streaming wiring as a follow-up.

@aaj3f aaj3f Aug 11, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, and thank you for chasing it into stream_query.rs — this was the best catch in the review. The sentence was writing a check the streaming surface couldn't cash, and the failure mode is the bad one: the page tells the reader that absence means unenforced, so a caller streaming a policy-filtered result would have read silence as "nothing filtered me". A false negative on the one question the page exists to answer.

I went with wiring it rather than scoping the prose. execute_view_streaming (fluree-db-api/src/view/stream_query.rs:215) now calls record_policy_enforcement before it prepares, exactly as its buffered sibling execute_view_tracked_with_r2rml does, and I put the dataset-side call in execute_dataset_into_with_r2rml (fluree-db-api/src/view/dataset_query.rs:658) since that's the sink-generic core the streaming dataset producer and the buffered "into" path already share — so the two can't diverge in setup later. The terminal end record then carries policy_enforcement under the same presence rule the buffered response uses, so a client can read absence identically on both surfaces.

On the protocol blast radius, which was the thing I wanted to be sure of before extending the record: end already carries t/fuel/time conditionally, so an optional field is the shape clients are built for, and nothing in the workspace deserializes these records into a deny_unknown_fields struct. Our own consumer (fluree-db-cli/src/commands/query_stream.rs:123) classifies on type and pulls fields individually with .get(), and in envelope mode passes every record through verbatim — so an older CLI against a newer server just ignores it. I added a consumer test for the older-server direction too (field absent ⇒ None, no invention).

Two things worth flagging that I did not change. The state is settled before the first row is pulled but only reported at the end — putting it on head would get it to the caller earlier, but head is emitted before execution begins and I'd rather not have two sources of truth for it; happy to revisit if you think early notification matters more than I'm weighting it. And policy (the per-policy counters) still isn't carried on the streaming surface — that one is data-shaped (it counts evaluations), so I left it off deliberately rather than by omission.

Tests: fluree-db-server/tests/stream_query_integration.rs now pins all four states over the real endpoint — enforced-with-grants, the opt-in off case, zero-policy deny-all, and anonymous-absent.

Comment thread fluree-db-core/src/tracking.rs Outdated
/// but no policy ran" or "the request ran unenforced" — the two states a caller
/// most needs separated.
///
/// This is a pure function of the request's policy inputs and the ledger's

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small precision point: "a pure function of the request's policy inputs and the ledger's policy configuration" understates what's involved — the view set includes stored policies selected through the identity's f:policyClass assignments, which are ledger data.

The oracle argument still holds (constant per caller/ledger, query-independent, and you have to be the identity to see it), so this is a wording fix rather than a design problem. The sentence should just say so rather than implying no ledger data participates.

@aaj3f aaj3f Aug 11, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right and I'm glad you pushed on the phrasing — "a pure function of the request's policy inputs and the ledger's policy configuration" was doing something I didn't intend, which is implying no ledger data participates at all. It plainly does: the view set is assembled from stored f:AccessPolicy nodes, and which ones land in it is decided by the identity's own f:policyClass assignments, which are flakes like any other.

Rewritten in fffad550e (pre-rebase SHA; the branch has since rebased onto main — head 83edb9d50 carries the same change) to say that outright and then rest the argument on the three properties that actually carry it — the value is settled when the view is built and doesn't depend on the data the query reads or on the query itself; it's constant for a given caller and ledger and moves only when the policy config or that caller's own assignments move; and you have to already be acting as the identity to see it, so what it discloses is your own authorization posture. I propagated the same correction to GraphDb::policy_enforcement and to the three docs pages that had inherited the loose phrasing, since it would have been odd to fix it in one place and leave the docs overclaiming.

Comment thread fluree-db-api/src/view/dataset.rs Outdated
match graph.policy_enforcement() {
Some(state) => {
let denies_all_data =
acc.as_ref().is_none_or(|a| a.denies_all_data) && state.denies_all_data;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I traced all four orderings — [unenforced, enforced-deny], [enforced-deny, unenforced], [deny, deny], [grant, deny] — and this produces the right answer in every one, so no correctness objection.

But it's hard to verify by reading: is_none_or(|a| a.denies_all_data) && state.denies_all_data combined with a None arm that seeds PolicyEnforcement::default(), whose enforced: false is then load-bearing for the trailing .filter, is a lot of implicit state for two booleans. A fold over (any_enforced, all_enforced_deny) would be a third the length and self-evident.

More actionable: this function has no unit test. The tracker's merge has one, but the dataset aggregation — where the ordering subtlety actually lives — doesn't.

@aaj3f aaj3f Aug 11, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair on both counts, and the "no unit test" half is the one that stung — you're right that the tracker's merge has coverage and the place where the ordering subtlety actually lives had none. Thanks for tracing the four orderings by hand; that's exactly the work the test should have been saving you.

Refactored in c50c5769a (pre-rebase SHA; the branch has since rebased onto main — head 83edb9d50 carries the same change) to the fold you sketched, over (any_enforced, all_deny) — any is an OR and all an AND over one pass, so the order-independence is now evident from the shape instead of needing a trace. I pulled it out as a free function aggregate_policy_enforcement taking impl IntoIterator<Item = Option<PolicyEnforcement>> rather than leaving it inline, mostly so the test can exercise the aggregation directly without standing up ledgers and policy wrappers to manufacture each ordering — the logic under test is the fold, and going through real GraphDbs would have made the test mostly fixture.

The test covers your four orderings plus empty and all-unenforced. The case I most wanted pinned is the one you called out: an unenforced graph has to clear the whole-dataset deny bit without clearing enforced, whichever side of an enforced graph it falls on, since one readable graph is enough for the request to return data.

self.track_meta
|| self.track_fuel
|| self.track_time
|| self.track_policy

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This flips the response shape for a request sending only fluree-track-policy: bare result array -> tracked envelope. Nobody could have depended on the tracked shape (the header was never parsed), but clients sending the header and parsing a bare array will break — including any CLI older than this build talking to a newer server. Worth a release note.

@aaj3f aaj3f Aug 11, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on who actually breaks here — I'd been thinking about it as "nobody could have depended on the tracked shape" and stopped there, which is true but is the wrong half of the population. The people at risk are the ones sending the header and parsing a bare array, and as you say that includes any CLI older than this build pointed at a newer server, where --track-policy has been a silent no-op all along.

Added to docs/reference/compatibility.md under a new "API Versioning → Behavior changes" section (9aa2e52b4 (pre-rebase SHA; the branch has since rebased onto main — head 83edb9d50 carries the same change)), covering what flips, who is affected, and the two ways to adapt — read result when the body is an object and the body itself when it's an array (which is what our CLI does, and works against either server), or stop sending the header. I documented the additive policy_enforcement field there too, including the streaming end-record form, so there's one place to look. Let me know if you'd rather this also ride the release notes proper when we cut the version — happy to add it there, I just didn't want the compat page and the notes to drift.

//! `TrackingFlags::as_request_headers` produces, so a rename or a dropped
//! branch on either side fails here.

#![cfg(feature = "server")]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct for CI (--all-features), but it makes the file invisible under a plain cargo test -p fluree-db-cli. Given the test exists precisely to catch drift, worth a line in the header saying so — otherwise someone renames a header locally, sees green, and ships it.

@aaj3f aaj3f Aug 11, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I may be misreading which invocation you had in mind, but server is a default feature of fluree-db-cli (fluree-db-cli/Cargo.toml:23, default = ["server", "iceberg", "shacl", "aws"]), so a plain cargo test -p fluree-db-cli does compile and run this file — I re-ran it just now to be sure and it reports 3 tests. So the drift scenario you describe (someone renames a header locally, sees green, ships it) shouldn't happen on a default-feature run.

Where you're right, and it's the same hazard: under --no-default-features, or any feature set that drops server, the file vanishes as a silently empty test binary — a crate-level cfg leaves nothing behind to report as skipped, so it looks like a pass. That's the vacuous-check failure mode we've been bitten by before, so it earns the comment either way.

So I've written the header note (9aa2e52b4 (pre-rebase SHA; the branch has since rebased onto main — head 83edb9d50 carries the same change)) to say precisely when it runs and when it disappears, and to tell anyone testing with a narrowed feature set to confirm these test names appear in the output before concluding the seam is intact. If you did mean --no-default-features all along then we're just agreeing and I've belabored it — apologies if so.

// `policy_enforcement` is a read-path signal and is never set on a
// transaction tally, so it is deliberately absent from the recorded
// mirror — adding a field would shift every postcard byte offset.
policy_enforcement: None,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the right call, and well documented — deliberately not adding the field so postcard offsets don't shift, reasoning in the comment, plus a round-trip test asserting None. Nothing to change.

@aaj3f aaj3f Aug 11, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you — and thanks for reading the postcard reasoning closely enough to confirm it, since that's the sort of thing that's easy to skim past as boilerplate. Leaving it exactly as is.

@bplatz bplatz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left some feedback below to look at before merge

aaj3f added 8 commits August 11, 2026 14:48
The per-policy tally only fills in when a policy executes, so a request
that ran under a policy context granting nothing and a request that ran
with no policy context at all both serialize the same empty map. Record
the enforcement state from the prepared view instead: whether a non-root
policy context governed the request, and whether its view-policy set is
empty under a fail-closed default.

Both bits are functions of the request's policy inputs and the ledger's
policy configuration, not of the data or the query, so the signal is
constant across every query a caller issues under the same context.
Requests with no policy inputs keep the output they had.
The CLI has always emitted this header for --track-policy and the header
table documents it, but the server had no constant for it and never read
it: the flag worked embedded and silently produced nothing over HTTP.
Parse it alongside the other granular track headers, count it as
tracking, and carry it through the header-to-opts injection.

The round-trip test drives the server router with the headers the CLI
itself builds, so neither half can be renamed without the other. The
CLI footer now also reports enforcement when no policy executed, which
is the case that previously printed nothing.
Three troubleshooting pages documented X-Fluree-Policy-Trace and
X-Fluree-Trace headers, with a worked policy_trace response. Neither
header has ever existed: unknown headers are dropped silently, so a
reader following our own guide to debug policy visibility got a normal
response and no explanation. Replace them with the tracking surface that
does exist.

Also: the ledger-config docs claimed an unconfigured ledger is fully
open with f:defaultAllow true, which is false for any request carrying
an identity — those fail closed. Say what actually happens, and add a
section on reading the policy tally against an empty result set,
including what an empty map and denies_all_data do and do not mean.
The read path had no policy-tally coverage at all: the one existing test
was on a transaction. Cover the three states an empty tally can mean —
policies ran, enforced but granting nothing, and unenforced — over HTTP,
including the x-fdb-policy-enforcement response header.

Also tightens the multi-query tracking assertion, which was a three-way
OR that passed on time alone and pinned nothing about policy.
The signal was absent on the NDJSON streaming surface while the docs
told readers that absence means unenforced — a false negative on the
endpoint most likely to carry large policy-filtered result sets.

Record it in the streaming executors and carry it on the terminal end
record, under the same presence rule the buffered response uses. The
field is additive: end already carries optional t/fuel/time, and the
CLI consumer reads fields individually and passes unknown ones through,
so an older client is unaffected.
Calling the value a pure function of the request and the ledger's
policy configuration understated what participates: the view set is
built from stored policy nodes, selected through the identity's own
f:policyClass assignments, which are ledger data.

The oracle argument does not depend on that being false. State what it
does rest on — the value is settled before execution and independent of
the data read and of the query, constant for a given caller and ledger,
and visible only to someone already acting as that identity.
The accumulator threaded an is_none_or guard, a default-seeded None
arm, and a trailing filter to carry two booleans — correct, but only
verifiable by tracing every graph ordering by hand. Extract the fold
over (any_enforced, all_deny), where any is an OR and all an AND over
one pass, so order-independence is evident.

The dataset aggregation is where the ordering subtlety lives and it had
no test; the new one covers both orderings of an unenforced graph beside
an enforced one, both orderings of a granting graph beside a denying
one, all-deny, all-unenforced, and empty.
Parsing fluree-track-policy flips the response for a request sending
only that header, from a bare result array to the tracked envelope.
Nobody could have depended on the tracked shape, but a client sending
the header and parsing an array will break — including a CLI older than
this build against a newer server.

Also records how the seam test's feature gating affects when it runs:
server is a default feature, so it runs on a plain cargo test, and only
a narrowed feature set skips it — silently.
@aaj3f

aaj3f commented Aug 11, 2026 •

Copy link
Copy Markdown
Contributor Author

@bplatz — on the two review-body notes:

The load-bearing claim, and stating it in the PR body:

Review-body note 1 — the load-bearing claim, and stating it in the PR body

Thank you for independently verifying that, genuinely — it's the one claim the whole design rests on, and I'd much rather it be checked by someone who didn't write it. Your trace is better than what I'd have written from memory: build_policy_set pushes every action-matching restriction into set.restrictions before it consults stats (fluree-db-policy/src/index.rs:91), and the SchemaHierarchy expansion only extends the targets of restrictions already in the set rather than adding or removing any — so restrictions.is_empty() is decided entirely by which policy nodes the request resolved to and can't move with the data.

I've put that in the PR body with the file:line and credited the verification to you. I also folded in the precision from your tracking.rs note there, so the body doesn't repeat the overclaim the code comment just stopped making.

Review-body note 2 — the #1625 doc-comment conflict

Agreed, and thanks for spotting the collision across the two PRs. Both rewrite the doc comment on PolicyDefaults::default_allow (fluree-db-core/src/ledger_config.rs:73-77). Keeping this PR's version on resolution, per your call — it additionally records that the field is only ever consulted for a request carrying policy inputs, which is the part that surprised people in the first place. Noted in the PR body as well so whoever does the merge doesn't have to reconstruct it from review threads.

Worth restating the ordering while we're here since it's easy to lose: #1625 should land first or together, because the corrected ledger-config docs in this PR describe f:defaultAllow config actually applying to identity-carrying requests, which is what #1625 makes true.

@aaj3f
aaj3f force-pushed the fix/policy-signal-and-docs branch from 9aa2e52 to 83edb9d Compare August 11, 2026 18:54
aaj3f added 2 commits August 11, 2026 15:46
Resolves the collision with the tri-state default-allow change.

ledger_config.rs: kept this branch's PolicyDefaults::default_allow doc
comment per review, folding in the one fact that only became true with
the tri-state change — the request-side option is now tri-state too.

policy_integration.rs and the cli test-module import list: both sides
appended independently, so both are kept. Verified by reconstructing
each side's tail against the merge base rather than hand-merging, so
no test from either branch is dropped.

extract/headers.rs and consensus/caching.rs merged cleanly; the
track-policy parsing and the tri-state default-allow parsing are
separate concerns and both survive with their tests.
denies_all_data reads the effective default_allow, so a ledger that
opens itself through f:defaultAllow must flip the bit even though the
request never names default-allow. Neither branch could test this
alone: before the tri-state change the configured value never reached
an identity-carrying request. Both orientations pinned.
@aaj3f
aaj3f merged commit a85e036 into main Aug 11, 2026
15 checks passed
@aaj3f
aaj3f deleted the fix/policy-signal-and-docs branch August 11, 2026 20:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants