Skip to content

fix(policy): key the class cache on the graph, not just the subject - #1580

Open
christophediprima wants to merge 1 commit into
fluree:mainfrom
christophediprima:fix/policy-class-cache-graph-key
Open

fix(policy): key the class cache on the graph, not just the subject#1580
christophediprima wants to merge 1 commit into
fluree:mainfrom
christophediprima:fix/policy-class-cache-graph-key

Conversation

@christophediprima

@christophediprima christophediprima commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

PolicyContext::class_cache is 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 collapse into one cache entry and whichever populated it first decides the classes for both.

Every f:onClass restriction 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-named query spans them — and docs/security/policy-in-queries.md:203-205 explicitly recommends f:onClass as 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:graphMap routing 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 a GraphDbRef, whose g_id is a public field, so the insert side now keys on the graph it actually read.
  • The two lookups live in 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 a g_id: GraphId.
  • class_cache becomes HashMap<(GraphId, Sid), Vec<Sid>>; cache_subject_classes and get_cached_subject_classes take the graph.

One design note worth a reviewer's eye. Callers pass the graph of the GraphDbRef they populated the cache from, rather than a literal. Populate and lookup therefore agree by construction rather than by convention. Two callers — block_fetch.rs and graph_commit_builder.rs — build that ref with a hardcoded graph 0, 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:graphMap work, 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.

`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.
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.

1 participant