Repository navigation
report policy enforcement state in tracked query meta - #1627
Conversation
bplatz
left a comment
There was a problem hiding this comment.
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.
|
|
||
| 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 |
There was a problem hiding this comment.
"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.
There was a problem hiding this comment.
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.
| /// 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| 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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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")] |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
Left some feedback below to look at before merge
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.
|
@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 bodyThank 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: 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 Review-body note 2 — the #1625 doc-comment conflictAgreed, and thanks for spotting the collision across the two PRs. Both rewrite the doc comment on 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 |
9aa2e52 to
83edb9d
Compare
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.
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 carrypolicy_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 anx-fdb-policy-enforcementresponse header for CSV/TSV callers, and on the NDJSON streaming endpoint it rides the terminalendrecord.Why this is safe to report. The whole design rests on
denies_all_databeing independent of the data, so it is worth stating the proof rather than the claim.denies_all_dataisview_set.restrictions.is_empty() && !default_allow.build_policy_setpushes every action-matching restriction intoset.restrictionsbefore consultingstats(fluree-db-policy/src/index.rs:91), and theSchemaHierarchyexpansion only extends the targets of restrictions already in the set — it never adds or removes one. Sorestrictions.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:AccessPolicynodes, selected through the identity's ownf:policyClassassignments, 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-policyrequest 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 documentedX-Fluree-Policy-Trace/X-Fluree-Traceheaders 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-policyflips 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 aflureeCLI older than this build against a newer server. Written up indocs/reference/compatibility.mdunder API Versioning → Behavior changes.Sequencing notes:
f:defaultAllowconfig actually applying to identity requests, which is what make governance default-allow tri-state so ledger config applies #1625 makes true — land that one first (or together).PolicyDefaults::default_allow(fluree-db-core/src/ledger_config.rs:73-77), a direct textual conflict. Keep this PR's version when resolving; it is the more informative of the two and additionally records that the field is only ever consulted for requests carrying policy inputs.