fix(policy): key the class cache on the graph, not just the subject - #1580
Open
christophediprima wants to merge 1 commit into
Open
fix(policy): key the class cache on the graph, not just the subject#1580christophediprima wants to merge 1 commit into
christophediprima wants to merge 1 commit into
Conversation
`PolicyContext::class_cache` was a `HashMap<Sid, Vec<Sid>>`. The same subject IRI can carry different `rdf:type` values in different named graphs, so two graphs sharing a subject collapsed into one entry and whichever populated it first decided the classes for both. Every `f:onClass` restriction evaluated for that subject afterwards used the wrong class set — allowing or denying depending on population order rather than on the data. That is order-dependent behaviour in an authorization path, not a stale-cache annoyance. It has gone unnoticed because it needs the same subject in two graphs to be observable, which is a shape almost nothing produces today. R2RML `rr:graphMap` routing produces exactly that shape. The graph was already available at both ends and simply not carried across: - `populate_class_cache` receives a `GraphDbRef`, whose `g_id` is public, so the insert side keys on the graph it actually read. - the two lookups sit in `filter_flakes_for_graph` / `allow_flake_for_graph`, the one boundary where the graph was being dropped. Both now take `g_id`. Callers pass the graph of the `GraphDbRef` they populated the cache from, so populate and lookup agree by construction rather than by convention. Two of them — `block_fetch.rs` and `graph_commit_builder.rs` — build that ref with a hardcoded graph 0, so their behaviour is unchanged here; that hardcoding is a separate defect and is left for its own change, but it now flows through to the lookup automatically once fixed rather than needing a second edit. The added tests pin the keying and the miss behaviour (a graph with no entry must miss rather than borrow another graph's classes). They cannot be run against the old code, since the accessors did not take a graph — demonstrating the wrong-decision outcome end to end needs an enforcer-level integration test, which is worth adding alongside the `rr:graphMap` work where the shape occurs naturally. Verified with CI's flags: `cargo clippy --all --all-features --all-targets` clean; `fluree-db-policy --all-features` 39 passed; `fluree-db-query` and `fluree-db-api` lib tests 904 and 1342 passed.
This was referenced Jul 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PolicyContext::class_cacheis aHashMap<Sid, Vec<Sid>>. The same subject IRI can carry differentrdf:typevalues in different named graphs, so two graphs sharing a subject collapse into one cache entry and whichever populated it first decides the classes for both.Every
f:onClassrestriction evaluated for that subject afterwards uses the wrong class set — allowing or denying based on population order rather than on the data. That is order-dependent behaviour in an authorization path, not a stale-cache annoyance.This is not a hypothetical shape. Named graphs are a general feature — any
from-namedquery spans them — anddocs/security/policy-in-queries.md:203-205explicitly recommendsf:onClassas the tool to reach for when "different graphs need different policy regimes". That is precisely the restriction this bug corrupts, so the documented workaround for graph-differentiated policy is the one that misbehaves on multi-graph data.It has gone unnoticed because it needs the same subject IRI present in two graphs with different types, which is rarer than multi-graph data generally. I came across it while auditing how far the graph identifier travels through the read path; R2RML
rr:graphMaprouting is the workload that produces the shape per row #1581, which is why I went looking. Nothing about the bug or the fix is specific to R2RML.What changes
The graph was already available at both ends and simply wasn't carried across:
populate_class_cache(fluree-db-policy/src/class_lookup.rs) receives aGraphDbRef, whoseg_idis a public field, so the insert side now keys on the graph it actually read.filter_flakes_for_graph/allow_flake_for_graph(fluree-db-query/src/policy/enforcer.rs) — the one boundary where the graph was being dropped. Both now take ag_id: GraphId.class_cachebecomesHashMap<(GraphId, Sid), Vec<Sid>>;cache_subject_classesandget_cached_subject_classestake the graph.One design note worth a reviewer's eye. Callers pass the graph of the
GraphDbRefthey populated the cache from, rather than a literal. Populate and lookup therefore agree by construction rather than by convention. Two callers —block_fetch.rsandgraph_commit_builder.rs— build that ref with a hardcoded graph0, so their behaviour is byte-identical after this change. That hardcoding is a separate defect and is deliberately left alone here, but because the lookup now follows the ref, fixing it later corrects both halves in one edit instead of two.Tests — two units in
evaluate.rs: the same subject cached with different classes in graphs 3 and 4 keeps both entries; a graph with no entry misses rather than borrowing another graph's classes (a miss degrades to "no classes", which is the conservative direction).Be aware of what those tests do not do. They cannot be run against the unfixed code, because the accessors did not take a graph at all — so they pin the new keying without demonstrating the old wrong decision. Showing the authorization outcome end to end needs an enforcer-level integration test over a real two-graph ledger. That belongs with the
rr:graphMapwork, where the data shape arises naturally, and I would rather say so than let the test count imply more coverage than it has.Why it is worth having on its own: it is a correctness bug in an authorization path, the fix is contained, and it stands independent of anything else I have open — it needs no other change to be worth landing, and nothing else needs to land first.