From 6fc5dab1d8d94da8c680c48e25b37822d785eb59 Mon Sep 17 00:00:00 2001 From: bplatz Date: Wed, 1 Jul 2026 22:56:52 -0400 Subject: [PATCH 1/5] fix(policy): enforce f:policySource and config policy defaults on writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit f:policySource was honored on the read/query path but ignored on every write/modify path, which hardcoded the default graph (g_id = 0): a policy relocated into a named graph — or sourced cross-ledger from a model ledger — was enforced on queries but silently not enforced on transactions. Config-declared policy defaults (f:policyClass, f:defaultAllow) also never merged into write-time governance, so requests without policy inputs ran as root even on configured ledgers. Fixes #1416. Changes: - New `build_transact_policy_context` (policy_view.rs): the write-side counterpart of `wrap_policy`. Resolves the ledger config at to_t, merges config policy defaults via merge_policy_opts, and dispatches f:policySource — same-ledger selectors through resolve_policy_source_g_ids (fail-closed on unknown graphs), cross-ledger f:ledger references through the ArtifactKind::PolicyRules resolver with restrictions interned into the data ledger's term space. Returns None (root) only when neither the request nor the config supplies any policy input; a cross-ledger source always builds a context, mirroring the read path. - Extracted `resolve_cross_ledger_policy_restrictions` from wrap_policy's inline cross-ledger block and shared it between the read and write paths, so the Phase 1a identity-mode rejection and the f:policyClass intersection filter (default {f:AccessPolicy}) cannot drift between them. - Rewired the write-path call sites onto the new builder: - consensus transact (LocalCommitter + Raft commit worker) via crate::local::build_policy_context, which now takes &Fluree and no longer short-circuits on empty request governance - credential_transact (verified-identity transactions) - push replication (build_policy_ctx_for_push; stage_commit_flakes now takes Option<&PolicyContext>) - commit-detail fetch (graph_commit_builder, keeping its per-request identity/policy_class opt-in gate) - CLI local insert/upsert/update (flags-only gate preserved) - block_fetch has no Fluree handle, so it resolves same-ledger f:policySource via resolve_policy_graphs_from_config and fails closed on cross-ledger configs instead of silently reading the default graph - Tests (it_policy_write_path.rs): config defaults enforced on writes with no request inputs; same-ledger named-graph f:policySource modify-deny; cross-ledger modify-deny resolved live against the model ledger (violating write rejected, untargeted write allowed); identity + cross-ledger fails closed; no-config/no-inputs still runs root. All verified to fail against the previous hardcoded-[0] behavior. - Docs: policy-in-transactions.md (new "Config-driven write enforcement" section), cross-ledger-policy.md (transactions engage automatically; queries keep the header gate), setting-groups.md, programmatic-policy.md (build_transact_policy_context as the recommended transaction entry point), and the cross-ledger design doc's scope section. --- docs/design/cross-ledger-model-enforcement.md | 9 +- docs/ledger-config/setting-groups.md | 2 + docs/security/cross-ledger-policy.md | 37 +- docs/security/policy-in-transactions.md | 12 + docs/security/programmatic-policy.md | 24 +- fluree-db-api/src/block_fetch.rs | 20 +- fluree-db-api/src/commit_transfer.rs | 22 +- fluree-db-api/src/graph_commit_builder.rs | 15 +- fluree-db-api/src/lib.rs | 4 +- fluree-db-api/src/policy_view.rs | 160 +++++- fluree-db-api/src/tx.rs | 21 +- fluree-db-api/src/view/fluree_ext.rs | 67 +-- fluree-db-api/tests/grp_policy.rs | 2 + fluree-db-api/tests/it_policy_write_path.rs | 474 ++++++++++++++++++ fluree-db-cli/src/commands/insert.rs | 8 +- fluree-db-consensus/src/local.rs | 36 +- fluree-db-consensus/src/raft/commit_worker.rs | 2 +- 17 files changed, 799 insertions(+), 116 deletions(-) create mode 100644 fluree-db-api/tests/it_policy_write_path.rs diff --git a/docs/design/cross-ledger-model-enforcement.md b/docs/design/cross-ledger-model-enforcement.md index 4555dc80ca..053197e180 100644 --- a/docs/design/cross-ledger-model-enforcement.md +++ b/docs/design/cross-ledger-model-enforcement.md @@ -454,7 +454,14 @@ mode can be added without rewriting the failure taxonomy. - `f:policySource` cross-ledger via `resolve_graph_ref`. The policy IR carries definitional/contextual term references separately so the model ledger contributes rules while the - data ledger contributes identity binding. + data ledger contributes identity binding. Enforced on both + the read path (`wrap_policy`) and the write path + (`build_transact_policy_context`, used by the consensus + transact pipeline, push replication, credentialed + transactions, and the CLI); the two share one restriction + resolver (`resolve_cross_ledger_policy_restrictions`) so the + identity-mode rejection and `f:policyClass` filter cannot + drift. - `f:constraintsSource` cross-ledger via the same shared resolver. M's `f:enforceUnique true` annotations on properties apply to D's transactions; a tx that would diff --git a/docs/ledger-config/setting-groups.md b/docs/ledger-config/setting-groups.md index bf7e2d0803..6e6e6207c7 100644 --- a/docs/ledger-config/setting-groups.md +++ b/docs/ledger-config/setting-groups.md @@ -38,6 +38,8 @@ Controls default policy enforcement behavior. When `f:policySource` is set, the policy loader scans the specified graph for policy rules instead of the default graph. This keeps policy rules separate from end-user data. If `f:policySource` is not set, policies are loaded from the default graph (backward compatible). +`f:policySource` and the policy defaults are honored on **both reads and writes**: queries load view rules from the configured graph, and transactions load `f:modify` rules from the same graph before staging. Config-declared `f:policyClass` / `f:defaultAllow` defaults apply to transactions even when the request itself carries no policy inputs — an operator who relocates policy into a named graph (or a model ledger) gets the same enforcement on writes as on reads. + **Cross-ledger references are supported on `f:policySource`.** The graph source can name another ledger via `f:ledger`, so a single model ledger can hold policy rules that govern many data ledgers. See [Cross-ledger policy](../security/cross-ledger-policy.md) for the configuration pattern and the contract on `f:policyClass` filtering, baseline `f:AccessPolicy` semantics, and the failure modes. **Not yet honored on `f:policySource`** (parsed by the config layer but rejected at request time with a clear error): `f:atT` temporal pinning, `f:trustPolicy` verification, `f:rollbackGuard` freshness constraints. Cross-ledger references are also supported on `f:constraintsSource`, `f:schemaSource` (single graph only — transitive `owl:imports` recursion across ledgers is not yet supported), `f:shapesSource`, and `f:rulesSource`. See [Cross-ledger policy](../security/cross-ledger-policy.md) for the end-to-end configuration patterns and failure modes shared by all five subsystems. diff --git a/docs/security/cross-ledger-policy.md b/docs/security/cross-ledger-policy.md index 79efc2a917..6dc592b158 100644 --- a/docs/security/cross-ledger-policy.md +++ b/docs/security/cross-ledger-policy.md @@ -11,7 +11,8 @@ All five `f:GraphRef`-shaped governance predicates support cross-ledger references today: - **Cross-ledger policy** (`f:policySource` with `f:ledger`) — - M's policy rule set is applied to queries against D. + M's policy rule set is applied to queries (`f:view`) and + transactions (`f:modify`) against D. - **Cross-ledger constraints** (`f:constraintsSource` with `f:ledger`) — M's `f:enforceUnique` annotations are applied to transactions against D. @@ -154,16 +155,26 @@ rules are opt-in — operators name the class to enroll them. ## Engaging policy enforcement -There's a subtlety in how the server's JSON-LD query route -chooses whether to invoke policy enforcement at all. Requests -without an `fluree-policy-class`, `fluree-identity`, or inline -`opts.policy` go through a no-policy fast path that bypasses the -cross-ledger dispatch. A configured `f:policySource` in `#config` -is **not** enough on its own to force enforcement at the HTTP -layer today. - -To engage cross-ledger policy via HTTP, send a request with at -least one of: +**Transactions engage cross-ledger policy automatically.** The +transact path (JSON-LD / SPARQL UPDATE / Turtle / TriG through +the server, push replication, credentialed transactions, and the +CLI's local mode with policy flags) resolves D's config before +staging: a cross-ledger `f:policySource` always builds a policy +context, and M's `f:modify` rules are enforced on the staged +flakes even when the request carries no policy inputs at all. +Config `f:defaultAllow` / `f:policyClass` defaults merge in the +same way they do for reads. + +For **queries**, there's a subtlety in how the server's JSON-LD +query route chooses whether to invoke policy enforcement at all. +Requests without an `fluree-policy-class`, `fluree-identity`, or +inline `opts.policy` go through a no-policy fast path that +bypasses the cross-ledger dispatch. A configured `f:policySource` +in `#config` is **not** enough on its own to force enforcement at +the HTTP query layer today. + +To engage cross-ledger policy on an HTTP query, send a request +with at least one of: - `fluree-policy-class: ` — the policy class header (the cleanest way to declare "use the configured policy"). Matching @@ -177,7 +188,9 @@ least one of: When using the in-process Rust API, calling `fluree.db_with_policy(ledger_id, &opts)` always engages the policy path, even with empty opts. Programmatic users don't see -this gating. +this gating. The write-side equivalent is +`build_transact_policy_context` — see +[Programmatic policy API (Rust)](programmatic-policy.md). ## Cross-ledger uniqueness constraints diff --git a/docs/security/policy-in-transactions.md b/docs/security/policy-in-transactions.md index 033abfd5a6..5f3460a673 100644 --- a/docs/security/policy-in-transactions.md +++ b/docs/security/policy-in-transactions.md @@ -83,6 +83,18 @@ Crucially, the policy is checked against the **flakes**, not the operation type. Enforcement is also independent of the **wire format**: the check runs on the staged flakes, so JSON-LD, SPARQL UPDATE, and Turtle / TriG / N-Triples writes are all governed by the same `f:modify` policy. Sending data as Turtle is not a way to bypass write policy. +## Config-driven write enforcement + +The ledger's `#config` graph governs writes the same way it governs reads: + +- **Policy defaults apply without request inputs.** When `f:policyDefaults` declares `f:policyClass` (and optionally `f:defaultAllow`), transactions build a policy context from those defaults even when the request carries no `fluree-identity` / `fluree-policy-class` headers or inline `opts.policy`. A ledger configured with a modify-deny rule rejects violating writes from anonymous requests, matching read-side behavior. +- **`f:policySource` redirects the rule lookup.** Policy rules relocated into a named graph (or a cross-ledger model ledger via `f:ledger`) are loaded from the configured source at transaction time — never silently from the default graph. Unknown graph selectors fail closed. +- **Cross-ledger sources always engage.** A cross-ledger `f:policySource` builds a policy context unconditionally (mirroring the read path): the model ledger's `f:modify` rules apply to every transaction against the data ledger. See [Cross-ledger policy](cross-ledger-policy.md). +- **Identity-mode + cross-ledger fails closed.** A request that sets an identity against a ledger with a cross-ledger `f:policySource` is rejected with a config error, the same Phase 1a contract the read path enforces. Use `f:policyClass` with cross-ledger configs. +- **Override control gates request-time overrides.** A request that supplies its own policy inputs replaces the config defaults only when the config's `f:overrideControl` permits it — see [Override control](../ledger-config/override-control.md). + +This applies uniformly across the server transact routes (local and Raft consensus), push replication, credentialed transactions, and the CLI's local mode with policy flags. + ## Targeting patterns ### Whitelist a property to a role diff --git a/docs/security/programmatic-policy.md b/docs/security/programmatic-policy.md index cad38aed41..32285d59c1 100644 --- a/docs/security/programmatic-policy.md +++ b/docs/security/programmatic-policy.md @@ -274,7 +274,29 @@ When multiple policies match a flake, they are combined using **Deny Overrides** ## Transactions with Policy -Policies can also be applied to transactions using the builder API: +Policies can also be applied to transactions using the builder API. The +recommended entry point is `build_transact_policy_context`, which honors +the ledger's `#config` graph the same way the server transact path does: +it merges config policy defaults (`f:policyClass`, `f:defaultAllow`) into +the supplied options and resolves `f:policySource` — same-ledger named +graphs and cross-ledger model references — before building the context. +It returns `None` when neither the request nor the config supplies any +policy input (run as root): + +```rust +let policy_ctx = fluree_db_api::build_transact_policy_context( + &fluree, + &ledger.snapshot, + ledger.novelty.as_ref(), + Some(ledger.novelty.as_ref()), + ledger.t(), + &qc_opts, +).await?; // -> Option +``` + +The lower-level `build_policy_context_from_opts` remains available when +you want to control the policy graphs yourself (it does no config +resolution and no defaults merge): ```rust use fluree_db_api::policy_builder; diff --git a/fluree-db-api/src/block_fetch.rs b/fluree-db-api/src/block_fetch.rs index 154004cfd6..c158b83c9f 100644 --- a/fluree-db-api/src/block_fetch.rs +++ b/fluree-db-api/src/block_fetch.rs @@ -381,11 +381,27 @@ pub async fn apply_policy_filter( let overlay: &dyn OverlayProvider = &NoOverlay; - let policy_ctx = - policy_builder::build_policy_context_from_opts(snapshot, overlay, None, to_t, &opts, &[0]) + // Config-aware: a configured `f:policySource` redirects the policy-rule + // lookup to the declared graph. No `Fluree` handle is available on this + // path, so a cross-ledger `f:policySource` fails closed here (the + // resolver rejects `f:ledger`) rather than silently falling back to the + // default graph. + let policy_graphs = + crate::policy_view::resolve_policy_graphs_from_config(snapshot, overlay, to_t) .await .map_err(|e| BlockFetchError::PolicyBuild(e.to_string()))?; + let policy_ctx = policy_builder::build_policy_context_from_opts( + snapshot, + overlay, + None, + to_t, + &opts, + &policy_graphs, + ) + .await + .map_err(|e| BlockFetchError::PolicyBuild(e.to_string()))?; + if policy_ctx.wrapper().is_root() { return Ok((flakes, false)); } diff --git a/fluree-db-api/src/commit_transfer.rs b/fluree-db-api/src/commit_transfer.rs index 4f89070337..888a553faf 100644 --- a/fluree-db-api/src/commit_transfer.rs +++ b/fluree-db-api/src/commit_transfer.rs @@ -21,7 +21,6 @@ use crate::dataset::GovernanceOptions; use crate::error::{ApiError, Result}; use crate::ledger_manager::LedgerWriteGuard; -use crate::policy_builder::build_policy_context_from_opts; use crate::tx::{IndexingMode, IndexingStatus}; use crate::{Fluree, IndexConfig, LedgerHandle}; use base64::Engine as _; @@ -421,7 +420,8 @@ impl Fluree { // 4.2 Policy enforcement: build policy context from opts against current state. let policy_ctx = - build_policy_ctx_for_push(&base_state, &evolving_novelty, current_t, opts).await?; + build_policy_ctx_for_push(self, &base_state, &evolving_novelty, current_t, opts) + .await?; // 4.3 Stage flakes (policy/backpressure). No WHERE/cancellation; flakes are prebuilt. let evolving_state = base_state.clone_with_novelty(Arc::new(evolving_novelty.clone())); @@ -429,7 +429,7 @@ impl Fluree { evolving_state, &c.commit.flakes, index_config, - &policy_ctx, + policy_ctx.as_ref(), &routing.graph_sids, ) .await @@ -885,19 +885,23 @@ fn validate_required_blobs( } async fn build_policy_ctx_for_push( + fluree: &Fluree, base: &LedgerState, evolving: &Novelty, current_t: i64, opts: &GovernanceOptions, -) -> Result { - // Build policy context from opts against current state (db + evolving novelty). - build_policy_context_from_opts( +) -> Result> { + // Build policy context from opts merged with the ledger's #config policy + // defaults, against current state (db + evolving novelty). Config-aware: + // honors f:policySource (same-ledger named graphs and cross-ledger model + // references) instead of assuming policy rules live in the default graph. + crate::policy_view::build_transact_policy_context( + fluree, &base.snapshot, evolving, Some(evolving), current_t, opts, - &[0], ) .await } @@ -906,13 +910,13 @@ async fn stage_commit_flakes( ledger: LedgerState, flakes: &[Flake], index_config: &IndexConfig, - policy_ctx: &PolicyContext, + policy_ctx: Option<&PolicyContext>, graph_sids: &HashMap, ) -> std::result::Result { let mut options = fluree_db_transact::StageOptions::new() .with_index_config(index_config) .with_graph_sids(graph_sids); - if !policy_ctx.wrapper().is_root() { + if let Some(policy_ctx) = policy_ctx.filter(|p| !p.wrapper().is_root()) { options = options.with_policy(policy_ctx); } fluree_db_transact::stage_flakes(ledger, flakes.to_vec(), options) diff --git a/fluree-db-api/src/graph_commit_builder.rs b/fluree-db-api/src/graph_commit_builder.rs index ba8f268854..669f364181 100644 --- a/fluree-db-api/src/graph_commit_builder.rs +++ b/fluree-db-api/src/graph_commit_builder.rs @@ -23,7 +23,7 @@ use crate::dataset::GovernanceOptions; use crate::format::iri::IriCompactor; use crate::graph::Graph; use crate::ledger_view::CommitRef; -use crate::{policy_builder, ApiError, Result}; +use crate::{ApiError, Result}; use fluree_db_core::commit::codec::read_commit; use fluree_db_core::{ContentId, ContentStore, FlakeValue, OverlayProvider, Tracker}; use fluree_db_novelty::Commit; @@ -282,19 +282,24 @@ impl<'a, 'g> CommitBuilder<'a, 'g> { ..Default::default() }; // Use the novelty overlay so policy rules in uncommitted - // transactions are visible to the policy builder. + // transactions are visible to the policy builder. Config-aware: + // a configured `f:policySource` (same-ledger named graph or + // cross-ledger model reference) redirects the policy-rule + // lookup instead of assuming the default graph. The + // identity/policy_class gate above is unchanged — commit-detail + // filtering stays opt-in per request. let overlay: &dyn OverlayProvider = snapshot.novelty.as_ref(); - let policy_ctx = policy_builder::build_policy_context_from_opts( + let policy_ctx = crate::policy_view::build_transact_policy_context( + self.graph.fluree, &snapshot.snapshot, overlay, Some(snapshot.novelty.as_ref()), commit.t, &opts, - &[0], ) .await?; - if !policy_ctx.wrapper().is_root() { + if let Some(policy_ctx) = policy_ctx.filter(|p| !p.wrapper().is_root()) { let enforcer = QueryPolicyEnforcer::new(Arc::new(policy_ctx)); let tracker = Tracker::disabled(); diff --git a/fluree-db-api/src/lib.rs b/fluree-db-api/src/lib.rs index 6510b4a506..9cd1cb274c 100644 --- a/fluree-db-api/src/lib.rs +++ b/fluree-db-api/src/lib.rs @@ -164,8 +164,8 @@ pub use pack::{ }; pub use policy_builder::identity_has_no_policies; pub use policy_view::{ - build_policy_context, wrap_identity_policy_view, wrap_policy_view, wrap_policy_view_historical, - PolicyWrappedView, + build_policy_context, build_transact_policy_context, wrap_identity_policy_view, + wrap_policy_view, wrap_policy_view_historical, PolicyWrappedView, }; pub use query::builder::{ DatasetQueryBuilder, FromQueryBuilder, GraphSourceMode, ViewQueryBuilder, diff --git a/fluree-db-api/src/policy_view.rs b/fluree-db-api/src/policy_view.rs index 18c5eae381..04fdc86999 100644 --- a/fluree-db-api/src/policy_view.rs +++ b/fluree-db-api/src/policy_view.rs @@ -205,6 +205,11 @@ pub async fn wrap_policy_view_historical<'a>( /// don't go through `wrap_policy` / `GraphDb` (e.g., server transact handlers, /// CLI insert) use this function and still get config-driven policy graphs. /// +/// Same-ledger only: a cross-ledger `f:policySource` (with `f:ledger`) fails +/// closed here. Callers with a `Fluree` handle should use +/// [`build_transact_policy_context`], which also merges config policy +/// defaults and resolves cross-ledger sources. +/// /// # Arguments /// /// * `snapshot` - The database snapshot to query against @@ -232,6 +237,159 @@ pub async fn build_policy_context( .await } +/// Resolve a cross-ledger `f:policySource` into policy restrictions +/// interned against the data ledger's term space. +/// +/// Shared between `wrap_policy` (read path) and +/// [`build_transact_policy_context`] (write path) so both sides apply +/// identical semantics: identity-mode rejection, `ArtifactKind::PolicyRules` +/// dispatch, and the policy-class intersection filter. +/// +/// The filter contract: the data ledger's configured `policy_class` set is +/// applied as an exact-IRI intersection on the wire's restrictions, OR +/// `{f:AccessPolicy}` when no policy_class is set. `f:AccessPolicy` is the +/// canonical / baseline policy class — declaring `f:policySource` +/// cross-ledger pulls those rules in automatically; custom-typed rules +/// require an explicit `f:policyClass` in D's config to be enforced. This is +/// the safer default than "load every structurally-policy-looking subject +/// from M," which would silently include rules the operator never opted into. +pub(crate) async fn resolve_cross_ledger_policy_restrictions( + snapshot: &LedgerSnapshot, + effective_opts: &GovernanceOptions, + source: &fluree_db_core::ledger_config::GraphSourceRef, + ctx: &mut crate::cross_ledger::ResolveCtx<'_>, +) -> Result> { + // Phase 1a: cross-ledger + identity-mode is not supported. The model + // ledger contributes policy rules; the data ledger contributes identity + // binding. Mixing them ambiguously is a fail-closed config error. + if effective_opts.identity.is_some() { + return Err(crate::error::ApiError::config( + "cross-ledger f:policySource cannot be combined with opts.identity \ + in Phase 1a; use opts.policy_class with the cross-ledger config", + )); + } + + let resolved = crate::cross_ledger::resolve_graph_ref( + source, + crate::cross_ledger::ArtifactKind::PolicyRules, + ctx, + ) + .await?; + let crate::cross_ledger::GovernanceArtifact::PolicyRules(wire) = &resolved.artifact else { + // resolve_graph_ref dispatches on ArtifactKind, so requesting + // PolicyRules must yield PolicyRules. Surfacing this as + // TranslationFailed rather than panicking keeps the failure path + // uniform for operators reading the response body. + return Err(crate::error::ApiError::CrossLedger( + crate::cross_ledger::CrossLedgerError::TranslationFailed { + ledger_id: resolved.model_ledger_id.clone(), + graph_iri: resolved.graph_iri.clone(), + detail: "resolver returned a non-PolicyRules artifact for an \ + ArtifactKind::PolicyRules request; this is a bug in \ + the resolver dispatch" + .into(), + }, + )); + }; + + const DEFAULT_POLICY_CLASS_IRI: &str = fluree_vocab::policy_iris::ACCESS_POLICY; + let filter: std::collections::HashSet = effective_opts + .policy_class + .as_ref() + .filter(|v| !v.is_empty()) + .map(|v| v.iter().cloned().collect()) + .unwrap_or_else(|| [DEFAULT_POLICY_CLASS_IRI.to_string()].into_iter().collect()); + + fluree_db_policy::wire_to_restrictions(wire, |iri| snapshot.encode_iri(iri), Some(&filter)) + .map_err(crate::error::ApiError::from) +} + +/// Build the policy context for a write (or other non-view enforcement +/// point), honoring the ledger's `#config` graph the same way `wrap_policy` +/// does on the read path. +/// +/// This is the write-side counterpart of `Fluree::wrap_policy`: +/// +/// 1. Resolves the ledger config at `to_t` and merges config policy defaults +/// (`f:policyClass`, `f:defaultAllow`, override control) into `opts` via +/// `merge_policy_opts` — so config-declared policy governs writes even +/// when the request itself carries no policy inputs. +/// 2. A cross-ledger `f:policySource` (with `f:ledger`) is resolved live +/// against the model ledger (`ArtifactKind::PolicyRules`, latest committed +/// M) and its restrictions are interned into this ledger's term space. +/// 3. A same-ledger `f:policySource` resolves to concrete graph IDs via +/// `resolve_policy_source_g_ids` (fail-closed on unknown selectors). +/// +/// Returns `Ok(None)` when neither the request nor the config supplies any +/// policy input — the transaction runs under root, matching the previous +/// behavior for unconfigured ledgers. A cross-ledger source always builds a +/// context (mirroring the read path, where the model ledger's rules apply +/// regardless of request inputs). +pub async fn build_transact_policy_context( + fluree: &crate::Fluree, + snapshot: &LedgerSnapshot, + overlay: &dyn OverlayProvider, + novelty_for_stats: Option<&Novelty>, + to_t: i64, + opts: &GovernanceOptions, +) -> Result> { + let resolved = + match crate::config_resolver::resolve_ledger_config(snapshot, overlay, to_t).await { + Ok(Some(c)) => Some(crate::config_resolver::resolve_effective_config(&c, None)), + Ok(None) => None, + Err(e) => { + return Err(crate::error::ApiError::config(format!( + "Failed to load ledger config while resolving transaction policy: {e}" + ))); + } + }; + + let effective_opts = match &resolved { + Some(r) => crate::config_resolver::merge_policy_opts(r, opts, None), + None => opts.clone(), + }; + + let source = resolved + .as_ref() + .and_then(|r| r.policy.as_ref()) + .and_then(|p| p.policy_source.as_ref()); + + if let Some(source) = source.filter(|s| s.ledger.is_some()) { + let ledger_id: String = snapshot.ledger_id.to_string(); + let mut ctx = crate::cross_ledger::ResolveCtx::new(&ledger_id, fluree); + let restrictions = + resolve_cross_ledger_policy_restrictions(snapshot, &effective_opts, source, &mut ctx) + .await?; + let policy_ctx = policy_builder::build_policy_context_from_opts_with_cross_ledger( + snapshot, + overlay, + novelty_for_stats, + to_t, + &effective_opts, + &[0], // identity-mode uses [0]; unused under cross-ledger + restrictions, + ) + .await?; + return Ok(Some(policy_ctx)); + } + + if !effective_opts.has_any_policy_inputs() { + return Ok(None); + } + + let policy_graphs = policy_builder::resolve_policy_source_g_ids(source, snapshot)?; + let policy_ctx = policy_builder::build_policy_context_from_opts( + snapshot, + overlay, + novelty_for_stats, + to_t, + &effective_opts, + &policy_graphs, + ) + .await?; + Ok(Some(policy_ctx)) +} + /// Wrap a ledger with identity-based policy via `f:policyClass` lookup. /// /// Convenience wrapper for identity-based policy wrapping. @@ -271,7 +429,7 @@ pub async fn wrap_identity_policy_view<'a>( /// Returns `[0]` (default graph) only when no config has been written to the /// ledger yet (`Ok(None)`) or no `f:policySource` is configured — in both /// cases the caller's policy rules, if any, live in the default graph. -async fn resolve_policy_graphs_from_config( +pub(crate) async fn resolve_policy_graphs_from_config( snapshot: &LedgerSnapshot, overlay: &dyn OverlayProvider, to_t: i64, diff --git a/fluree-db-api/src/tx.rs b/fluree-db-api/src/tx.rs index a2594df086..3040150431 100644 --- a/fluree-db-api/src/tx.rs +++ b/fluree-db-api/src/tx.rs @@ -2620,20 +2620,33 @@ impl crate::Fluree { let verified = crate::credential::verify_credential(credential)?; - // Build policy context with verified identity + // Build policy context with verified identity. Config-aware: a + // configured `f:policySource` redirects the policy-rule lookup to + // the declared graph, and config policy defaults merge in — same + // semantics as the consensus transact path. (A cross-ledger + // f:policySource fails closed here: identity-mode + cross-ledger is + // rejected in Phase 1a.) let opts = crate::GovernanceOptions { identity: Some(verified.did.clone()), ..Default::default() }; - let policy_ctx = crate::policy_builder::build_policy_context_from_opts( + let policy_ctx = crate::policy_view::build_transact_policy_context( + self, &ledger.snapshot, ledger.novelty.as_ref(), Some(ledger.novelty.as_ref()), ledger.t(), &opts, - &[0], ) - .await?; + .await? + .ok_or_else(|| { + // opts.identity is always set above, so a same-ledger build + // always yields a context; None would mean the gate logic + // changed underneath us. + ApiError::internal( + "credential transact expected a policy context for a verified identity", + ) + })?; // Context propagation: inject parent context if subject doesn't have one let mut txn_json = verified.subject.clone(); diff --git a/fluree-db-api/src/view/fluree_ext.rs b/fluree-db-api/src/view/fluree_ext.rs index 10fcc211a3..027f661584 100644 --- a/fluree-db-api/src/view/fluree_ext.rs +++ b/fluree-db-api/src/view/fluree_ext.rs @@ -681,17 +681,6 @@ impl Fluree { let is_cross_ledger = source.is_some_and(|s| s.ledger.is_some()); if is_cross_ledger { - // Phase 1a: cross-ledger + identity-mode is not supported. - // The model ledger contributes policy rules; the data - // ledger contributes identity binding. Mixing them - // ambiguously is a fail-closed config error. - if effective_opts.identity.is_some() { - return Err(crate::error::ApiError::config( - "cross-ledger f:policySource cannot be combined with opts.identity \ - in Phase 1a; use opts.policy_class with the cross-ledger config", - )); - } - let source = source.expect("checked above"); // Seed from any prior governance-context capture stored // on the view (e.g., an earlier `wrap_policy` in the @@ -707,60 +696,16 @@ impl Fluree { self, (**view.cross_ledger_resolved_ts()).clone(), ); - let resolved = crate::cross_ledger::resolve_graph_ref( + // Identity-mode rejection (Phase 1a), PolicyRules dispatch, and + // the policy-class intersection filter all live in the shared + // helper so read and write paths can't drift. + let restrictions = crate::policy_view::resolve_cross_ledger_policy_restrictions( + &view.snapshot, + &effective_opts, source, - crate::cross_ledger::ArtifactKind::PolicyRules, &mut ctx, ) .await?; - let crate::cross_ledger::GovernanceArtifact::PolicyRules(wire) = &resolved.artifact - else { - // resolve_graph_ref dispatches on ArtifactKind, so - // requesting PolicyRules must yield PolicyRules. - // Surfacing this as TranslationFailed rather than - // panicking keeps the failure path uniform for - // operators reading the response body. - return Err(crate::error::ApiError::CrossLedger( - crate::cross_ledger::CrossLedgerError::TranslationFailed { - ledger_id: resolved.model_ledger_id.clone(), - graph_iri: resolved.graph_iri.clone(), - detail: "resolver returned a non-PolicyRules artifact for an \ - ArtifactKind::PolicyRules request; this is a bug in \ - the resolver dispatch" - .into(), - }, - )); - }; - - // Apply the data ledger's configured policy_class set as - // an exact-IRI intersection filter on the wire's - // restrictions. The contract is: - // - // filter = effective_opts.policy_class, OR - // {f:AccessPolicy} when no policy_class is set. - // - // f:AccessPolicy is the canonical / baseline policy class - // — declaring `f:policySource` cross-ledger pulls those - // rules in automatically. Custom-typed rules require - // an explicit `f:policyClass` in D's config to be - // enforced. This is the safer default than "load every - // structurally-policy-looking subject from M," which - // would silently include rules the operator never opted - // into. - const DEFAULT_POLICY_CLASS_IRI: &str = fluree_vocab::policy_iris::ACCESS_POLICY; - let filter: std::collections::HashSet = effective_opts - .policy_class - .as_ref() - .filter(|v| !v.is_empty()) - .map(|v| v.iter().cloned().collect()) - .unwrap_or_else(|| [DEFAULT_POLICY_CLASS_IRI.to_string()].into_iter().collect()); - let snapshot_ref = &view.snapshot; - let restrictions = fluree_db_policy::wire_to_restrictions( - wire, - |iri| snapshot_ref.encode_iri(iri), - Some(&filter), - ) - .map_err(crate::error::ApiError::from)?; let policy_ctx = crate::policy_builder::build_policy_context_from_opts_with_cross_ledger( diff --git a/fluree-db-api/tests/grp_policy.rs b/fluree-db-api/tests/grp_policy.rs index efa68c95b5..9fcd707d9a 100644 --- a/fluree-db-api/tests/grp_policy.rs +++ b/fluree-db-api/tests/grp_policy.rs @@ -27,3 +27,5 @@ mod it_policy_time_travel; mod it_policy_tracking; #[path = "it_policy_tx.rs"] mod it_policy_tx; +#[path = "it_policy_write_path.rs"] +mod it_policy_write_path; diff --git a/fluree-db-api/tests/it_policy_write_path.rs b/fluree-db-api/tests/it_policy_write_path.rs new file mode 100644 index 0000000000..4e51970816 --- /dev/null +++ b/fluree-db-api/tests/it_policy_write_path.rs @@ -0,0 +1,474 @@ +//! Write-path policy enforcement driven by the ledger's `#config` graph. +//! +//! `build_transact_policy_context` is the write-side counterpart of +//! `wrap_policy`: it merges config policy defaults (`f:policyClass`, +//! `f:defaultAllow`) into the request's governance options and resolves +//! `f:policySource` — same-ledger named graphs AND cross-ledger model +//! references — before building the `PolicyContext` a transaction stages +//! under. The consensus transact path (local + Raft), credential transact, +//! push, and the CLI all route through it. +//! +//! Before this existed, writes built policy exclusively from request +//! inputs against the default graph: config-declared policy was enforced +//! on reads but silently ignored on writes (issue #1416). + +#![cfg(feature = "native")] + +use crate::support::{assert_index_defaults, genesis_ledger}; +use fluree_db_api::{ + build_transact_policy_context, CommitOpts, FlureeBuilder, GovernanceOptions, IndexConfig, + TxnOpts, +}; +use serde_json::json; + +fn config_graph_iri(ledger_id: &str) -> String { + format!("urn:fluree:{ledger_id}#config") +} + +fn test_index_config() -> IndexConfig { + IndexConfig { + reindex_min_bytes: 100_000, + reindex_max_bytes: 1_000_000_000, + } +} + +/// No config, no request inputs → the transaction runs under root +/// (`None`), preserving the pre-existing behavior for unconfigured +/// ledgers. +#[tokio::test] +async fn no_config_no_inputs_yields_root() { + let fluree = FlureeBuilder::memory().build_memory(); + let ledger = genesis_ledger(&fluree, "policy/write-root:main"); + + let ctx = build_transact_policy_context( + &fluree, + &ledger.snapshot, + ledger.novelty.as_ref(), + Some(ledger.novelty.as_ref()), + ledger.t(), + &GovernanceOptions::default(), + ) + .await + .expect("build"); + assert!( + ctx.is_none(), + "no config + no request inputs must run under root" + ); +} + +/// Config-declared policy defaults (`f:policyClass` + `f:defaultAllow`) +/// govern writes even when the request carries NO policy inputs. The +/// policy rules live in the default graph; only the defaults-merge is +/// under test here. +#[tokio::test] +async fn config_policy_class_defaults_enforced_on_writes() { + assert_index_defaults(); + let fluree = FlureeBuilder::memory().build_memory(); + let ledger_id = "policy/write-config-defaults:main"; + let ledger0 = genesis_ledger(&fluree, ledger_id); + + // Seed data (registers ex: namespace) plus the write policy itself in + // the default graph: deny modifying ex:ssn, typed ex:WritePolicy. + let r1 = fluree + .insert( + ledger0, + &json!({ + "@context": {"ex": "http://example.org/ns/", "f": "https://ns.flur.ee/db#"}, + "@graph": [ + {"@id": "ex:alice", "@type": "ex:User", "ex:ssn": "111-11-1111"}, + { + "@id": "ex:noSsnWrite", + "@type": "ex:WritePolicy", + "f:required": true, + "f:onProperty": {"@id": "ex:ssn"}, + "f:action": {"@id": "f:modify"}, + "f:allow": false + } + ] + }), + ) + .await + .expect("seed data + policy"); + + // Config: defaultAllow=true so ONLY the ex:ssn rule blocks anything; + // policyClass opts the ex:WritePolicy-typed rule in. + let config_iri = config_graph_iri(ledger_id); + let r2 = fluree + .stage_owned(r1.ledger) + .upsert_turtle(&format!( + r" + @prefix f: . + @prefix rdf: . + @prefix ex: . + + GRAPH <{config_iri}> {{ + rdf:type f:LedgerConfig . + f:policyDefaults . + f:defaultAllow true . + f:policyClass ex:WritePolicy . + }} + " + )) + .execute() + .await + .expect("seed config"); + let ledger = r2.ledger; + + // Empty request opts: everything comes from config. + let ctx = build_transact_policy_context( + &fluree, + &ledger.snapshot, + ledger.novelty.as_ref(), + Some(ledger.novelty.as_ref()), + ledger.t(), + &GovernanceOptions::default(), + ) + .await + .expect("build") + .expect("config policyClass must produce a policy context for writes"); + + let cfg = test_index_config(); + let denied_turtle = "@prefix ex: .\nex:bob ex:ssn \"999-99-9999\" .\n"; + let denied = fluree + .insert_turtle_with_opts( + ledger.clone(), + denied_turtle, + TxnOpts::default(), + CommitOpts::default(), + &cfg, + Some(&ctx), + ) + .await; + assert!( + denied.is_err(), + "config-declared modify-deny on ex:ssn must reject the write, got: {denied:?}" + ); + + let allowed_turtle = "@prefix ex: .\nex:bob ex:name \"Bob\" .\n"; + let allowed = fluree + .insert_turtle_with_opts( + ledger, + allowed_turtle, + TxnOpts::default(), + CommitOpts::default(), + &cfg, + Some(&ctx), + ) + .await; + assert!( + allowed.is_ok(), + "defaultAllow=true must let unrelated writes through, got: {:?}", + allowed.err() + ); +} + +/// A same-ledger `f:policySource` pointing at a named graph: the write +/// path must load policy rules from THAT graph (previously it hardcoded +/// the default graph, where no rules exist, and allowed everything). +#[tokio::test] +async fn config_policy_source_named_graph_enforced_on_writes() { + assert_index_defaults(); + let fluree = FlureeBuilder::memory().build_memory(); + let ledger_id = "policy/write-named-graph-source:main"; + let ledger0 = genesis_ledger(&fluree, ledger_id); + + // Seed data in the default graph (no policy rules there). + let r1 = fluree + .insert( + ledger0, + &json!({ + "@context": {"ex": "http://example.org/ns/"}, + "@id": "ex:alice", + "@type": "ex:User", + "ex:ssn": "111-11-1111" + }), + ) + .await + .expect("seed data"); + + // Policy rules live exclusively in a named graph; config redirects + // the policy-rule lookup there via f:policySource. + let policy_graph_iri = "http://example.org/d-policies"; + let config_iri = config_graph_iri(ledger_id); + let r2 = fluree + .stage_owned(r1.ledger) + .upsert_turtle(&format!( + r" + @prefix f: . + @prefix rdf: . + @prefix ex: . + + GRAPH <{policy_graph_iri}> {{ + ex:noSsnWrite + rdf:type ex:WritePolicy ; + f:required true ; + f:onProperty ex:ssn ; + f:action f:modify ; + f:allow false . + }} + + GRAPH <{config_iri}> {{ + rdf:type f:LedgerConfig . + f:policyDefaults . + f:defaultAllow true . + f:policyClass ex:WritePolicy . + f:policySource . + rdf:type f:GraphRef ; + f:graphSource . + f:graphSelector <{policy_graph_iri}> . + }} + " + )) + .execute() + .await + .expect("seed policy graph + config"); + let ledger = r2.ledger; + + let ctx = build_transact_policy_context( + &fluree, + &ledger.snapshot, + ledger.novelty.as_ref(), + Some(ledger.novelty.as_ref()), + ledger.t(), + &GovernanceOptions::default(), + ) + .await + .expect("build") + .expect("config with f:policySource must produce a policy context"); + + let cfg = test_index_config(); + let denied_turtle = "@prefix ex: .\nex:bob ex:ssn \"999-99-9999\" .\n"; + let denied = fluree + .insert_turtle_with_opts( + ledger.clone(), + denied_turtle, + TxnOpts::default(), + CommitOpts::default(), + &cfg, + Some(&ctx), + ) + .await; + assert!( + denied.is_err(), + "modify-deny loaded from the f:policySource graph must reject the write, got: {denied:?}" + ); + + // Root control: the identical write with no policy context succeeds, + // proving the rejection above came from the named-graph rules. + let ok = fluree + .insert_turtle_with_opts( + ledger, + denied_turtle, + TxnOpts::default(), + CommitOpts::default(), + &cfg, + None, + ) + .await; + assert!( + ok.is_ok(), + "root write must succeed without the policy context, got: {:?}", + ok.err() + ); +} + +/// Cross-ledger `f:policySource`: model ledger M holds the policy rules; +/// data ledger D's config points at M. A write to D that violates M's +/// modify rules must be rejected — with NO policy inputs on the request. +/// This is the write-side counterpart of the read-path enforcement in +/// `it_policy_cross_ledger.rs`. +#[tokio::test] +async fn cross_ledger_policy_source_enforced_on_writes() { + assert_index_defaults(); + let fluree = FlureeBuilder::memory().build_memory(); + + // --- model ledger M: modify-deny on ex:ssn in a named policy graph + let model_id = "policy/write-xledger/model:main"; + let model = genesis_ledger(&fluree, model_id); + let policy_graph_iri = "http://example.org/m-policies"; + fluree + .stage_owned(model) + .upsert_turtle(&format!( + r" + @prefix f: . + @prefix rdf: . + @prefix ex: . + + GRAPH <{policy_graph_iri}> {{ + ex:noSsnWrite + rdf:type f:AccessPolicy ; + f:required true ; + f:onProperty ex:ssn ; + f:action f:modify ; + f:allow false . + }} + " + )) + .execute() + .await + .expect("seed M policy graph"); + + // --- data ledger D: data + cross-ledger config, no policy IRIs in D + let data_id = "policy/write-xledger/data:main"; + let data = genesis_ledger(&fluree, data_id); + let r1 = fluree + .insert( + data, + &json!({ + "@context": {"ex": "http://example.org/ns/"}, + "@id": "ex:alice", + "@type": "ex:User", + "ex:ssn": "111-11-1111" + }), + ) + .await + .expect("seed D data"); + + let config_iri = config_graph_iri(data_id); + let r2 = fluree + .stage_owned(r1.ledger) + .upsert_turtle(&format!( + r" + @prefix f: . + @prefix rdf: . + + GRAPH <{config_iri}> {{ + rdf:type f:LedgerConfig . + f:policyDefaults . + f:defaultAllow true . + f:policySource . + rdf:type f:GraphRef ; + f:graphSource . + f:ledger <{model_id}> ; + f:graphSelector <{policy_graph_iri}> . + }} + " + )) + .execute() + .await + .expect("seed D cross-ledger config"); + let ledger = r2.ledger; + + // Empty request opts: a cross-ledger source must still build a + // context — M's rules govern D regardless of request inputs (the + // default f:AccessPolicy class filter applies). + let ctx = build_transact_policy_context( + &fluree, + &ledger.snapshot, + ledger.novelty.as_ref(), + Some(ledger.novelty.as_ref()), + ledger.t(), + &GovernanceOptions::default(), + ) + .await + .expect("build") + .expect("cross-ledger f:policySource must produce a policy context for writes"); + + let cfg = test_index_config(); + let denied_turtle = "@prefix ex: .\nex:bob ex:ssn \"999-99-9999\" .\n"; + let denied = fluree + .insert_turtle_with_opts( + ledger.clone(), + denied_turtle, + TxnOpts::default(), + CommitOpts::default(), + &cfg, + Some(&ctx), + ) + .await; + assert!( + denied.is_err(), + "M's modify-deny on ex:ssn must reject the write to D, got: {denied:?}" + ); + + let allowed_turtle = "@prefix ex: .\nex:bob ex:name \"Bob\" .\n"; + let allowed = fluree + .insert_turtle_with_opts( + ledger, + allowed_turtle, + TxnOpts::default(), + CommitOpts::default(), + &cfg, + Some(&ctx), + ) + .await; + assert!( + allowed.is_ok(), + "defaultAllow=true must let writes M's rules don't target through, got: {:?}", + allowed.err() + ); +} + +/// Identity-mode + cross-ledger `f:policySource` fails closed on the +/// write builder, matching the read-path Phase 1a contract. +#[tokio::test] +async fn cross_ledger_plus_identity_fails_closed_on_writes() { + let fluree = FlureeBuilder::memory().build_memory(); + + let model_id = "policy/write-xledger-id/model:main"; + let model = genesis_ledger(&fluree, model_id); + let policy_graph_iri = "http://example.org/m-policies"; + fluree + .stage_owned(model) + .upsert_turtle(&format!( + r" + @prefix f: . + @prefix rdf: . + @prefix ex: . + + GRAPH <{policy_graph_iri}> {{ + ex:rule1 rdf:type f:AccessPolicy ; f:action f:modify ; f:allow true . + }} + " + )) + .execute() + .await + .expect("seed M"); + + let data_id = "policy/write-xledger-id/data:main"; + let data = genesis_ledger(&fluree, data_id); + let config_iri = config_graph_iri(data_id); + let r1 = fluree + .stage_owned(data) + .upsert_turtle(&format!( + r" + @prefix f: . + @prefix rdf: . + + GRAPH <{config_iri}> {{ + rdf:type f:LedgerConfig . + f:policyDefaults . + f:policySource . + rdf:type f:GraphRef ; + f:graphSource . + f:ledger <{model_id}> ; + f:graphSelector <{policy_graph_iri}> . + }} + " + )) + .execute() + .await + .expect("seed D config"); + let ledger = r1.ledger; + + let opts = GovernanceOptions { + identity: Some("http://example.org/users/alice".into()), + ..Default::default() + }; + let err = build_transact_policy_context( + &fluree, + &ledger.snapshot, + ledger.novelty.as_ref(), + Some(ledger.novelty.as_ref()), + ledger.t(), + &opts, + ) + .await + .expect_err("identity + cross-ledger must fail closed on the write builder"); + + let msg = err.to_string(); + assert!( + msg.contains("identity") && msg.contains("cross-ledger"), + "expected fail-closed diagnostic mentioning both, got: {msg}" + ); +} diff --git a/fluree-db-cli/src/commands/insert.rs b/fluree-db-cli/src/commands/insert.rs index d605a9e49f..ae9db65d3d 100644 --- a/fluree-db-cli/src/commands/insert.rs +++ b/fluree-db-cli/src/commands/insert.rs @@ -257,7 +257,11 @@ pub async fn build_policy_ctx( } let ledger = fluree.ledger(alias).await?; let opts = policy.to_options().map_err(CliError::Usage)?; - let ctx = fluree_db_api::build_policy_context( + // Config-aware: honors f:policySource (same-ledger named graphs and + // cross-ledger model references) and merges config policy defaults, + // matching server-side transact enforcement. + let ctx = fluree_db_api::build_transact_policy_context( + fluree, &ledger.snapshot, ledger.novelty.as_ref(), Some(ledger.novelty.as_ref()), @@ -265,7 +269,7 @@ pub async fn build_policy_ctx( &opts, ) .await?; - Ok(Some(ctx)) + Ok(ctx) } /// Print transaction result from remote server JSON response. diff --git a/fluree-db-consensus/src/local.rs b/fluree-db-consensus/src/local.rs index e4c843bbbc..8bb1510d16 100644 --- a/fluree-db-consensus/src/local.rs +++ b/fluree-db-consensus/src/local.rs @@ -113,7 +113,8 @@ impl Committer for LocalCommitter { // `CommitOpts` / `TrackingOptions`. let mut last_error: Option = None; for attempt in 1..=MAX_TXN_RETRIES { - let policy_ctx = build_policy_context(&ledger_handle, &governance).await?; + let policy_ctx = + build_policy_context(&self.fluree, &ledger_handle, &governance).await?; // Cypher lowers to a `Txn` here — under the write lock and re-resolved // each retry attempt — rather than pre-lock in the route. A conditional @@ -362,25 +363,31 @@ pub(crate) fn execution_failure(err: ApiError) -> SubmissionError { } } -/// Build a [`PolicyContext`] from the request's policy inputs. +/// Build a [`PolicyContext`] from the request's policy inputs merged with +/// the ledger's `#config` policy defaults. /// -/// Returns `Ok(None)` when there are no policy inputs — the transaction -/// runs under root. The context is built from a snapshot of the ledger -/// this node is about to stage against, so policy enforcement reflects -/// the same state the transaction commits onto. Building it here, rather -/// than having the caller pre-build and pass a context, keeps the policy -/// context bound to the executing node's state — the shape a replicated -/// implementation needs. +/// Returns `Ok(None)` when neither the request nor the ledger config +/// supplies any policy input — the transaction runs under root. The +/// context is built from a snapshot of the ledger this node is about to +/// stage against, so policy enforcement reflects the same state the +/// transaction commits onto. Building it here, rather than having the +/// caller pre-build and pass a context, keeps the policy context bound to +/// the executing node's state — the shape a replicated implementation +/// needs. +/// +/// Delegates to `fluree_db_api::build_transact_policy_context`, which +/// resolves `f:policySource` (same-ledger named graphs AND cross-ledger +/// model references) and applies config `f:policyClass` / `f:defaultAllow` +/// defaults — so writes are governed by the same config the read path +/// enforces via `wrap_policy`. pub(crate) async fn build_policy_context( + fluree: &Fluree, ledger_handle: &LedgerHandle, governance: &GovernanceOptions, ) -> Result, SubmissionError> { - if !governance.has_any_policy_inputs() { - return Ok(None); - } - let snap = ledger_handle.snapshot().await; - fluree_db_api::build_policy_context( + fluree_db_api::build_transact_policy_context( + fluree, &snap.snapshot, snap.novelty.as_ref(), Some(snap.novelty.as_ref()), @@ -388,7 +395,6 @@ pub(crate) async fn build_policy_context( governance, ) .await - .map(Some) .map_err(execution_failure) } diff --git a/fluree-db-consensus/src/raft/commit_worker.rs b/fluree-db-consensus/src/raft/commit_worker.rs index 3f5e380053..6275316c6f 100644 --- a/fluree-db-consensus/src/raft/commit_worker.rs +++ b/fluree-db-consensus/src/raft/commit_worker.rs @@ -517,7 +517,7 @@ impl Worker { .await .map_err(|e| stage_failure(&format!("ledger load failed: {e}")))?; - let policy_ctx = build_policy_context(&ledger_handle, &governance) + let policy_ctx = build_policy_context(&self.staging.fluree, &ledger_handle, &governance) .await .map_err(submission_to_stage)?; From cd2426ce0607519bf66c8e12a245bdd93fee0dbd Mon Sep 17 00:00:00 2001 From: bplatz Date: Thu, 2 Jul 2026 06:48:21 -0400 Subject: [PATCH 2/5] fix(policy): identity binds ?$identity under cross-ledger f:policySource instead of failing closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross-ledger guard rejected any request carrying an identity, even when a f:policyClass was available (from D's config or the request) to drive rule loading — the exact case the design intends: rules load by policy class, the identity binds ?$identity contextually. Since authenticated deployments attach an identity to virtually every request, cross-ledger policy governance was unusable outside anonymous / policy-class-only requests, and an identity-carrying write to a cross-ledger-governed ledger returned a hard config error. The naive fix (relax the guard alone) would have been worse than the bug: build_policy_context_from_opts_inner gave the identity branch unconditional priority, and that branch ignores cross_ledger_restrictions entirely — a relaxed guard would have routed identity-carrying requests into same-ledger identity-mode, silently dropping M's rules (fail-open). Changes: - resolve_cross_ledger_policy_restrictions: the class filter is now an explicit chain — request policy_class → config f:policyClass → {f:AccessPolicy} (anonymous requests only). The config's class is passed separately because merge_policy_opts returns request opts unchanged when the request carries any policy input and override is permitted, so an identity-only request never sees the config's class through the merge. An identity-carrying request with no class anywhere still fails closed: the identity is bind-only and can't select rules, so the operator must name what governs. - build_policy_context_from_opts_inner: the cross-ledger branch now takes priority over identity-mode. Under cross-ledger, the identity is bind-only — new resolve_identity_binding_sid resolves it against D (strict encode + subject-existence check, mirroring identity-mode's three-state binding contract) and populates ?$identity for f:query rules, without consulting the identity's D-local f:policyClass triples (a cross-ledger f:policySource declares M the policy authority). - Both read (wrap_policy) and write (build_transact_policy_context) builders pass the config policy class through the shared helper, so the contract cannot drift between paths. Credentialed transactions (inherently identity-carrying) now work against cross-ledger-governed ledgers whenever D's config declares a f:policyClass. Tests: - cross_ledger_identity_with_config_policy_class_enforced_on_writes — the reported scenario: identity + config policyClass builds a context (no error), M's modify-deny rejects the violating write, untargeted writes pass. - cross_ledger_identity_binding_drives_fquery_modify_rule — an owner-only f:query rule in M allows the identity to write its own user's email and rejects writes to another user's, proving ?$identity binds live. - identity_with_policy_class_engages_cross_ledger_rules (read) — the same owner-only rule filters another user's email from query results while the identity's own stays visible. - Existing identity-without-policy-class fail-closed tests unchanged. - All three new tests verified to fail under BOTH temp-reverts: the old blanket guard (fail-closed regression) and an identity-branch-first stub (the silent fail-open bypass). Docs: cross-ledger-policy.md gains an "Identity binding under cross-ledger policy" section (bind-only contract, class-filter chain, the defaultAllow/override-control merge subtlety); limitations table, policy-in-transactions.md, and the design doc scope updated to match. --- docs/design/cross-ledger-model-enforcement.md | 13 +- docs/security/cross-ledger-policy.md | 49 +++- docs/security/policy-in-transactions.md | 2 +- fluree-db-api/src/policy_builder.rs | 141 +++++++-- fluree-db-api/src/policy_view.rs | 87 ++++-- fluree-db-api/src/tx.rs | 7 +- fluree-db-api/src/view/fluree_ext.rs | 16 +- fluree-db-api/tests/it_policy_cross_ledger.rs | 123 +++++++- fluree-db-api/tests/it_policy_write_path.rs | 277 ++++++++++++++++++ 9 files changed, 636 insertions(+), 79 deletions(-) diff --git a/docs/design/cross-ledger-model-enforcement.md b/docs/design/cross-ledger-model-enforcement.md index 053197e180..f58d61bf1b 100644 --- a/docs/design/cross-ledger-model-enforcement.md +++ b/docs/design/cross-ledger-model-enforcement.md @@ -484,10 +484,15 @@ mode can be added without rewriting the failure taxonomy. - Reserved-feature rejection: `f:atT`, `f:trustPolicy`, and `f:rollbackGuard` are surfaced as `UnsupportedFeature` rather than silently ignored. -- Identity-mode + cross-ledger policy combination fails closed - with a config error — the design's "M contributes rules, D - contributes identity" boundary is enforced at the request - surface. +- Identity binding under cross-ledger policy: a request identity + is bind-only — it resolves against D to populate `?$identity` + for M's `f:query` rules and never selects rules (the design's + "M contributes rules, D contributes identity binding" + boundary). Rule selection is exclusively the policy-class + filter chain (request → config → `{f:AccessPolicy}` for + anonymous requests); an identity-carrying request with no + policy class anywhere fails closed with a config error rather + than silently defaulting. ### Reserved diff --git a/docs/security/cross-ledger-policy.md b/docs/security/cross-ledger-policy.md index 6dc592b158..b25d818655 100644 --- a/docs/security/cross-ledger-policy.md +++ b/docs/security/cross-ledger-policy.md @@ -180,8 +180,9 @@ with at least one of: cleanest way to declare "use the configured policy"). Matching the class in D's config (e.g., `f:AccessPolicy`) is the natural choice. -- `fluree-identity: ` — an identity header. Identity-mode - has a different contract; see below. +- `fluree-identity: ` — an identity header. Under + cross-ledger the identity is bind-only; see + [Identity binding](#identity-binding-under-cross-ledger-policy). - `opts.policy` in the body — inline JSON-LD policy. This still merges with cross-ledger rules. @@ -192,6 +193,48 @@ this gating. The write-side equivalent is `build_transact_policy_context` — see [Programmatic policy API (Rust)](programmatic-policy.md). +## Identity binding under cross-ledger policy + +An identity on the request (`fluree-identity` header, +`opts.identity`, or a verified credential's DID) is **bind-only** +under a cross-ledger `f:policySource`: + +- The identity resolves against **D** (identities are a + data-ledger concept — M never contributes identity records) and + populates `?$identity` for any `f:query` rules in M's policy + set. An owner-only rule authored in M therefore works across + every governed data ledger, with each D binding its own + identities. +- The identity **never selects rules**. Same-ledger identity-mode + loads policies via the identity's `f:policyClass` triples; + under cross-ledger those D-local triples are intentionally not + consulted — declaring a cross-ledger `f:policySource` makes M + the policy authority, and rule selection is exclusively the + policy-class filter chain: + + 1. the request's `policy_class` (when present), + 2. else the config's `f:policyClass`, + 3. else — for anonymous requests only — `{f:AccessPolicy}`. + +- Because the identity can't select rules, an identity-carrying + request with **no policy class anywhere** (request or config) + fails closed: the operator must name which classes govern. + In practice, setting `f:policyClass` in D's config (as in the + configuration example above) makes authenticated requests work + with no per-request changes. +- An identity IRI with no subject node in D yields an unbound + `?$identity`: `f:query` rules referencing it match nothing, so + `f:required` rules deny — the same contract as same-ledger + identity-mode's unknown-identity case. + +One merge subtlety: an identity counts as a request policy input, +so under the default `f:overrideControl` (`f:OverrideAll`) the +request's options take precedence and the config's +`f:defaultAllow` is **not** merged for identity-carrying requests +(same long-standing contract as same-ledger reads). Send the +`fluree-default-allow` header explicitly, or set a stricter +override control if the config should always win. + ## Cross-ledger uniqueness constraints Same two-ledger pattern, different subsystem. M holds an @@ -464,7 +507,7 @@ closed when configured: | `f:atT` (temporal pinning of M) | Request fails with `UnsupportedFeature { feature: "f:atT", phase: "Phase 3" }`. | | `f:trustPolicy` (commit-signer allowlist) | Request fails with `UnsupportedFeature`. | | `f:rollbackGuard` (freshness constraints) | Request fails with `UnsupportedFeature`. | -| `opts.identity` + cross-ledger `f:policySource` | Request fails with a config error. Identity-mode loads policies via the identity's `f:policyClass` triples, which would have to resolve in D (the identity isn't an M concept); combining the two modes ambiguously is rejected rather than silently choosing one. Use `opts.policy_class` with cross-ledger configs. | +| `opts.identity` + cross-ledger `f:policySource` **with no policy class anywhere** | Request fails with a config error. The identity is bind-only under cross-ledger (see [Identity binding](#identity-binding-under-cross-ledger-policy)) and can't select rules, so a policy class must be named on the request or in D's config. With a class available, identity-carrying requests work normally. | | `f:policySource` with `f:graphSelector` naming M's `#config` or `#txn-meta` | Request fails with `ReservedGraphSelected` before any storage read on M. | | Transitive `owl:imports` across model ledgers (`f:schemaSource` recursion) | Not yet honored. Imports inside M's schema graph are projected but the resolver doesn't follow them across ledger boundaries. | diff --git a/docs/security/policy-in-transactions.md b/docs/security/policy-in-transactions.md index 5f3460a673..53c2181377 100644 --- a/docs/security/policy-in-transactions.md +++ b/docs/security/policy-in-transactions.md @@ -90,7 +90,7 @@ The ledger's `#config` graph governs writes the same way it governs reads: - **Policy defaults apply without request inputs.** When `f:policyDefaults` declares `f:policyClass` (and optionally `f:defaultAllow`), transactions build a policy context from those defaults even when the request carries no `fluree-identity` / `fluree-policy-class` headers or inline `opts.policy`. A ledger configured with a modify-deny rule rejects violating writes from anonymous requests, matching read-side behavior. - **`f:policySource` redirects the rule lookup.** Policy rules relocated into a named graph (or a cross-ledger model ledger via `f:ledger`) are loaded from the configured source at transaction time — never silently from the default graph. Unknown graph selectors fail closed. - **Cross-ledger sources always engage.** A cross-ledger `f:policySource` builds a policy context unconditionally (mirroring the read path): the model ledger's `f:modify` rules apply to every transaction against the data ledger. See [Cross-ledger policy](cross-ledger-policy.md). -- **Identity-mode + cross-ledger fails closed.** A request that sets an identity against a ledger with a cross-ledger `f:policySource` is rejected with a config error, the same Phase 1a contract the read path enforces. Use `f:policyClass` with cross-ledger configs. +- **Identities are bind-only under cross-ledger.** A request identity (header, `opts.identity`, or a verified credential's DID) resolves against the data ledger and populates `?$identity` for the model ledger's `f:query` rules — it never selects rules the way same-ledger identity-mode does. Rule selection is the policy-class chain (request `policy_class` → config `f:policyClass` → `{f:AccessPolicy}` for anonymous requests). An identity-carrying request with no policy class anywhere fails closed; declaring `f:policyClass` in the config makes authenticated writes work with no per-request changes. See [Cross-ledger policy → Identity binding](cross-ledger-policy.md#identity-binding-under-cross-ledger-policy). - **Override control gates request-time overrides.** A request that supplies its own policy inputs replaces the config defaults only when the config's `f:overrideControl` permits it — see [Override control](../ledger-config/override-control.md). This applies uniformly across the server transact routes (local and Raft consensus), push replication, credentialed transactions, and the CLI's local mode with policy flags. diff --git a/fluree-db-api/src/policy_builder.rs b/fluree-db-api/src/policy_builder.rs index a3616d0697..4688fe06ae 100644 --- a/fluree-db-api/src/policy_builder.rs +++ b/fluree-db-api/src/policy_builder.rs @@ -134,16 +134,22 @@ pub async fn build_policy_context_from_opts( /// `cross_ledger_restrictions` is a pre-materialized list produced /// against a model ledger by the cross-ledger resolver and /// translated into D's term space via -/// `fluree_db_policy::wire_to_restrictions` (with D's configured -/// `policy_class` set already applied as a filter). When supplied, -/// the local same-ledger policy load (`load_policies_by_class` / -/// `parse_inline_policy`) is bypassed for the class / inline-policy -/// branch — those restrictions are used as-is. Identity loading and -/// `?$identity` binding still run locally against D per the -/// identity contract in the design doc. +/// `fluree_db_policy::wire_to_restrictions` (with the policy-class +/// filter chain already applied). When supplied, the local +/// same-ledger policy load (`load_policies_by_identity` / +/// `load_policies_by_class` / `parse_inline_policy`) is bypassed +/// for rule selection — those restrictions are used as-is, plus any +/// inline `opts.policy` merge. /// -/// `policy_graphs` is still consulted for the identity-mode path -/// (`opts.identity` set) because identity binding always resolves +/// Identity contract: `opts.identity` is **bind-only** under +/// cross-ledger. It resolves against D to populate `?$identity` for +/// f:query rules; it never selects rules (same-ledger identity-mode +/// consults the identity's D-local `f:policyClass` triples — those +/// are intentionally ignored here because a cross-ledger +/// `f:policySource` declares M the policy authority). +/// +/// `policy_graphs` is still consulted for the identity binding's +/// subject-existence check because identity binding always resolves /// against the data ledger; cross-ledger never contributes identity /// records. pub async fn build_policy_context_from_opts_with_cross_ledger( @@ -204,14 +210,60 @@ async fn build_policy_context_from_opts_inner( // Load policies and resolve identity SID. // - // When opts.identity is set, load_policies_by_identity returns a three-state enum - // distinguishing identity-not-in-ledger, identity-exists-with-no-policies, and - // identity-exists-with-policies. The distinction matters for binding `?$identity` - // in policy_values (only possible when we have a concrete SID), not for gating - // access — `opts.default_allow` governs in all three cases. + // When opts.identity is set (same-ledger), load_policies_by_identity returns a + // three-state enum distinguishing identity-not-in-ledger, + // identity-exists-with-no-policies, and identity-exists-with-policies. The + // distinction matters for binding `?$identity` in policy_values (only possible + // when we have a concrete SID), not for gating access — `opts.default_allow` + // governs in all three cases. // - // Priority: identity > policy_class > policy > policy_values["?$identity"] - let (identity_sid, restrictions) = if let Some(identity_iri) = &opts.identity { + // Priority: cross-ledger restrictions > identity > policy_class > policy > + // policy_values["?$identity"] + let (identity_sid, restrictions) = if let Some(mut merged) = cross_ledger_restrictions { + // Cross-ledger short-circuit: the resolver already materialized + // restrictions from the model ledger, filtered by the policy-class + // chain. Rule selection is complete before this function runs. + // + // Identity contract: an identity on the request is BIND-ONLY here. + // It resolves against the data ledger to populate `?$identity` for + // f:query rules — it never selects rules the way same-ledger + // identity-mode does (via the identity's f:policyClass triples in + // D). Those D-local triples are intentionally not consulted: a + // cross-ledger f:policySource declares M the policy authority. + // An identity with no subject node in D yields an unbound + // `?$identity` (f:query rules referencing it won't match), same as + // identity-mode's NotFound. + // + // opts.policy (inline JSON-LD) still applies and gets merged below. + // Moving — not cloning — the owned input keeps model-ledger policy + // sets (which can be large: each `PolicyRestriction` carries + // strings + hash sets) from paying a per-request copy. + let identity_sid = if let Some(identity_iri) = &opts.identity { + let resolved = + resolve_identity_binding_sid(snapshot, overlay, to_t, identity_iri, policy_graphs) + .await?; + if let Some(sid) = &resolved { + policy_values.insert("?$identity".to_string(), sid.clone()); + } + resolved + } else if let Some(sid) = policy_values.get("?$identity") { + Some(sid.clone()) + } else if let Some(pv) = &opts.policy_values { + if pv.contains_key("?$identity") { + return Err(ApiError::query( + "?$identity provided in policy-values but could not be encoded", + )); + } + None + } else { + None + }; + + if let Some(policy_json) = &opts.policy { + merged.extend(parse_inline_policy(snapshot, policy_json)?); + } + (identity_sid, merged) + } else if let Some(identity_iri) = &opts.identity { match load_policies_by_identity(snapshot, overlay, to_t, identity_iri, policy_graphs) .await? { @@ -248,22 +300,7 @@ async fn build_policy_context_from_opts_inner( None }; - let restrictions = if let Some(mut merged) = cross_ledger_restrictions { - // Cross-ledger short-circuit: the resolver already - // materialized restrictions from the model ledger and - // (per the identity contract) the wire artifact has been - // filtered by opts.policy_class. opts.policy (inline - // JSON-LD) still applies and gets merged below. - // - // Moving — not cloning — the owned input keeps - // model-ledger policy sets (which can be large: each - // `PolicyRestriction` carries strings + hash sets) from - // paying a per-request copy. - if let Some(policy_json) = &opts.policy { - merged.extend(parse_inline_policy(snapshot, policy_json)?); - } - merged - } else if let Some(classes) = &opts.policy_class { + let restrictions = if let Some(classes) = &opts.policy_class { load_policies_by_class(snapshot, overlay, to_t, classes, policy_graphs).await? } else if let Some(policy_json) = &opts.policy { parse_inline_policy(snapshot, policy_json)? @@ -395,6 +432,46 @@ enum IdentityLookupResult { }, } +/// Resolve an identity IRI to a bindable SID **without loading its policies**. +/// +/// Used under cross-ledger `f:policySource`, where rule selection is +/// exclusively the wire's policy-class filter and the identity contributes +/// only the `?$identity` binding. Mirrors identity-mode's three-state +/// contract for the binding decision: `None` when the IRI is unresolvable or +/// has no subject node in the searched graphs (identity-mode's `NotFound` — +/// no binding), `Some(sid)` when the subject exists (with or without +/// D-local policies, which are intentionally not consulted here). +async fn resolve_identity_binding_sid( + snapshot: &LedgerSnapshot, + overlay: &dyn fluree_db_core::OverlayProvider, + to_t: i64, + identity_iri: &str, + graphs: &[fluree_db_core::GraphId], +) -> Result> { + let identity_sid = match resolve_identity_iri_to_sid(snapshot, identity_iri) { + Ok(sid) => sid, + Err(_) => return Ok(None), + }; + + let range_opts = RangeOptions::default().with_flake_limit(1); + for &g_id in graphs { + let db = GraphDbRef::new(snapshot, g_id, overlay, to_t); + let exists = db + .range_with_opts( + IndexType::Spot, + RangeTest::Eq, + RangeMatch::subject(identity_sid.clone()), + range_opts.clone(), + ) + .await + .map_err(|e| ApiError::internal(format!("identity existence check failed: {e}")))?; + if !exists.is_empty() { + return Ok(Some(identity_sid)); + } + } + Ok(None) +} + /// Look up the policies for `identity_iri` via its `f:policyClass` property. /// /// Returns an [`IdentityLookupResult`] that distinguishes whether the identity diff --git a/fluree-db-api/src/policy_view.rs b/fluree-db-api/src/policy_view.rs index 04fdc86999..3379e5ef48 100644 --- a/fluree-db-api/src/policy_view.rs +++ b/fluree-db-api/src/policy_view.rs @@ -242,32 +242,60 @@ pub async fn build_policy_context( /// /// Shared between `wrap_policy` (read path) and /// [`build_transact_policy_context`] (write path) so both sides apply -/// identical semantics: identity-mode rejection, `ArtifactKind::PolicyRules` -/// dispatch, and the policy-class intersection filter. -/// -/// The filter contract: the data ledger's configured `policy_class` set is -/// applied as an exact-IRI intersection on the wire's restrictions, OR -/// `{f:AccessPolicy}` when no policy_class is set. `f:AccessPolicy` is the -/// canonical / baseline policy class — declaring `f:policySource` -/// cross-ledger pulls those rules in automatically; custom-typed rules -/// require an explicit `f:policyClass` in D's config to be enforced. This is -/// the safer default than "load every structurally-policy-looking subject -/// from M," which would silently include rules the operator never opted into. +/// identical semantics: the class-filter chain, the identity contract, and +/// the `ArtifactKind::PolicyRules` dispatch. +/// +/// The filter contract: rules materialized from M are intersected (exact +/// IRI) against the first non-empty entry in the chain +/// +/// `effective_opts.policy_class` → `config_policy_class` → +/// `{f:AccessPolicy}` (anonymous requests only). +/// +/// `config_policy_class` is passed separately because `merge_policy_opts` +/// returns the request opts unchanged when the request carries any policy +/// input and override is permitted — an identity-only request would +/// otherwise never see the config's `f:policyClass`. +/// +/// The identity contract: an identity on the request **binds `?$identity` +/// against D and never selects rules from M** — rule selection under +/// cross-ledger is exclusively the class filter (M contributes rules, D +/// contributes identity binding). Because the identity can't select rules, +/// an identity-carrying request with no policy class anywhere fails closed +/// rather than silently falling back to the `{f:AccessPolicy}` default: the +/// operator must name which classes govern. +/// +/// `f:AccessPolicy` is the canonical / baseline policy class — declaring +/// `f:policySource` cross-ledger pulls those rules in automatically for +/// anonymous requests; custom-typed rules require an explicit +/// `f:policyClass` in D's config to be enforced. This is the safer default +/// than "load every structurally-policy-looking subject from M," which +/// would silently include rules the operator never opted into. pub(crate) async fn resolve_cross_ledger_policy_restrictions( snapshot: &LedgerSnapshot, effective_opts: &GovernanceOptions, + config_policy_class: Option<&[String]>, source: &fluree_db_core::ledger_config::GraphSourceRef, ctx: &mut crate::cross_ledger::ResolveCtx<'_>, ) -> Result> { - // Phase 1a: cross-ledger + identity-mode is not supported. The model - // ledger contributes policy rules; the data ledger contributes identity - // binding. Mixing them ambiguously is a fail-closed config error. - if effective_opts.identity.is_some() { + const DEFAULT_POLICY_CLASS_IRI: &str = fluree_vocab::policy_iris::ACCESS_POLICY; + let filter: std::collections::HashSet = if let Some(classes) = effective_opts + .policy_class + .as_ref() + .filter(|v| !v.is_empty()) + { + classes.iter().cloned().collect() + } else if let Some(classes) = config_policy_class.filter(|v| !v.is_empty()) { + classes.iter().cloned().collect() + } else if effective_opts.identity.is_none() { + [DEFAULT_POLICY_CLASS_IRI.to_string()].into_iter().collect() + } else { return Err(crate::error::ApiError::config( - "cross-ledger f:policySource cannot be combined with opts.identity \ - in Phase 1a; use opts.policy_class with the cross-ledger config", + "cross-ledger f:policySource with an identity requires an explicit \ + f:policyClass (on the request or in the ledger config) to select \ + which of the model ledger's rules apply; the identity only binds \ + ?$identity and never selects rules", )); - } + }; let resolved = crate::cross_ledger::resolve_graph_ref( source, @@ -292,14 +320,6 @@ pub(crate) async fn resolve_cross_ledger_policy_restrictions( )); }; - const DEFAULT_POLICY_CLASS_IRI: &str = fluree_vocab::policy_iris::ACCESS_POLICY; - let filter: std::collections::HashSet = effective_opts - .policy_class - .as_ref() - .filter(|v| !v.is_empty()) - .map(|v| v.iter().cloned().collect()) - .unwrap_or_else(|| [DEFAULT_POLICY_CLASS_IRI.to_string()].into_iter().collect()); - fluree_db_policy::wire_to_restrictions(wire, |iri| snapshot.encode_iri(iri), Some(&filter)) .map_err(crate::error::ApiError::from) } @@ -357,9 +377,18 @@ pub async fn build_transact_policy_context( if let Some(source) = source.filter(|s| s.ledger.is_some()) { let ledger_id: String = snapshot.ledger_id.to_string(); let mut ctx = crate::cross_ledger::ResolveCtx::new(&ledger_id, fluree); - let restrictions = - resolve_cross_ledger_policy_restrictions(snapshot, &effective_opts, source, &mut ctx) - .await?; + let config_policy_class = resolved + .as_ref() + .and_then(|r| r.policy.as_ref()) + .and_then(|p| p.policy_class.as_deref()); + let restrictions = resolve_cross_ledger_policy_restrictions( + snapshot, + &effective_opts, + config_policy_class, + source, + &mut ctx, + ) + .await?; let policy_ctx = policy_builder::build_policy_context_from_opts_with_cross_ledger( snapshot, overlay, diff --git a/fluree-db-api/src/tx.rs b/fluree-db-api/src/tx.rs index 3040150431..14afb6f1e7 100644 --- a/fluree-db-api/src/tx.rs +++ b/fluree-db-api/src/tx.rs @@ -2623,9 +2623,10 @@ impl crate::Fluree { // Build policy context with verified identity. Config-aware: a // configured `f:policySource` redirects the policy-rule lookup to // the declared graph, and config policy defaults merge in — same - // semantics as the consensus transact path. (A cross-ledger - // f:policySource fails closed here: identity-mode + cross-ledger is - // rejected in Phase 1a.) + // semantics as the consensus transact path. Under a cross-ledger + // f:policySource the verified identity is bind-only (?$identity); + // rule selection needs a f:policyClass in the ledger config, else + // this fails closed. let opts = crate::GovernanceOptions { identity: Some(verified.did.clone()), ..Default::default() diff --git a/fluree-db-api/src/view/fluree_ext.rs b/fluree-db-api/src/view/fluree_ext.rs index 027f661584..ec49999a9e 100644 --- a/fluree-db-api/src/view/fluree_ext.rs +++ b/fluree-db-api/src/view/fluree_ext.rs @@ -696,12 +696,22 @@ impl Fluree { self, (**view.cross_ledger_resolved_ts()).clone(), ); - // Identity-mode rejection (Phase 1a), PolicyRules dispatch, and - // the policy-class intersection filter all live in the shared - // helper so read and write paths can't drift. + // The class-filter chain, identity contract (bind-only, never a + // rule selector), and PolicyRules dispatch all live in the shared + // helper so read and write paths can't drift. The config's + // policy_class is passed separately: merge_policy_opts returns + // the request opts unchanged when the request carries any policy + // input and override is permitted, so an identity-only request + // would otherwise never see the config's f:policyClass. + let config_policy_class = view + .resolved_config + .as_ref() + .and_then(|c| c.policy.as_ref()) + .and_then(|p| p.policy_class.as_deref()); let restrictions = crate::policy_view::resolve_cross_ledger_policy_restrictions( &view.snapshot, &effective_opts, + config_policy_class, source, &mut ctx, ) diff --git a/fluree-db-api/tests/it_policy_cross_ledger.rs b/fluree-db-api/tests/it_policy_cross_ledger.rs index 4bc828a14f..167002f9ab 100644 --- a/fluree-db-api/tests/it_policy_cross_ledger.rs +++ b/fluree-db-api/tests/it_policy_cross_ledger.rs @@ -616,11 +616,126 @@ async fn omitted_policy_class_defaults_to_access_policy_only() { ); } +/// Identity + cross-ledger with a policy class available (from D's +/// config): rules load from M via the class filter and the identity +/// binds `?$identity` against D, driving M's f:query rules. The +/// owner-only view rule hides bob's email from aliceIdentity while +/// alice's own email stays visible — proving both that the request +/// no longer fails closed and that the binding is live. +#[tokio::test] +async fn identity_with_policy_class_engages_cross_ledger_rules() { + let fluree = FlureeBuilder::memory().build_memory(); + + let model_id = "test/cross-ledger-e2e/id-bind-model:main"; + let model = genesis_ledger(&fluree, model_id); + let policy_graph_iri = "http://example.org/id-bind-policies"; + // Full IRIs inside f:query — it executes against D. + let owner_query = + r#"{"where": {"@id": "?$identity", "http://example.org/ns/user": {"@id": "?$this"}}}"#; + fluree + .stage_owned(model) + .upsert_turtle(&format!( + r#" + @prefix f: . + @prefix rdf: . + @prefix ex: . + + GRAPH <{policy_graph_iri}> {{ + ex:ownerEmailOnly + rdf:type f:AccessPolicy ; + f:required true ; + f:onProperty ex:email ; + f:action f:view ; + f:query """{owner_query}""" . + }} + "# + )) + .execute() + .await + .expect("seed M owner-only view rule"); + + let data_id = "test/cross-ledger-e2e/id-bind-data:main"; + let data = genesis_ledger(&fluree, data_id); + let r1 = fluree + .insert( + data, + &json!({ + "@context": {"ex": "http://example.org/ns/"}, + "@graph": [ + {"@id": "ex:alice", "@type": "ex:User", "ex:name": "Alice", "ex:email": "alice@flur.ee"}, + {"@id": "ex:bob", "@type": "ex:User", "ex:name": "Bob", "ex:email": "bob@flur.ee"}, + {"@id": "ex:aliceIdentity", "ex:user": {"@id": "ex:alice"}} + ] + }), + ) + .await + .expect("seed D users + identity"); + let data = r1.ledger; + + let config_iri = config_graph_iri(data_id); + fluree + .stage_owned(data) + .upsert_turtle(&format!( + r" + @prefix f: . + @prefix rdf: . + + GRAPH <{config_iri}> {{ + rdf:type f:LedgerConfig . + f:policyDefaults . + f:defaultAllow true . + f:policyClass f:AccessPolicy . + f:policySource . + rdf:type f:GraphRef ; + f:graphSource . + f:ledger <{model_id}> ; + f:graphSelector <{policy_graph_iri}> . + }} + " + )) + .execute() + .await + .expect("seed D cross-ledger config with policyClass"); + + // Identity-carrying request — previously a hard config error. + let opts = GovernanceOptions { + identity: Some("http://example.org/ns/aliceIdentity".into()), + ..Default::default() + }; + let wrapped = fluree + .db_with_policy(data_id, &opts) + .await + .expect("identity + config policyClass must not fail closed"); + + let emails = fluree + .query( + &wrapped, + &json!({ + "@context": {"ex": "http://example.org/ns/"}, + "select": ["?who", "?email"], + "where": {"@id": "?who", "ex:email": "?email"} + }), + ) + .await + .expect("query emails under cross-ledger identity binding"); + let rendered = emails + .to_jsonld(&wrapped.snapshot) + .expect("jsonld") + .to_string(); + assert!( + rendered.contains("alice@flur.ee"), + "aliceIdentity must see its own user's email via ?$identity binding, got {rendered}" + ); + assert!( + !rendered.contains("bob@flur.ee"), + "aliceIdentity must NOT see bob's email — M's owner-only rule must filter it, got {rendered}" + ); +} + /// Combining `opts.identity` with cross-ledger `f:policySource` is -/// a fail-closed config error in Phase 1a: the model ledger -/// contributes policy rules, the data ledger contributes identity -/// binding, and mixing them via identity-mode would attribute -/// policies ambiguously across ledger boundaries. +/// a fail-closed config error when no policy class is available +/// anywhere (request or config): the identity is bind-only and can't +/// select rules, so the operator must name which classes govern. #[tokio::test] async fn cross_ledger_plus_identity_mode_fails_closed() { let fluree = FlureeBuilder::memory().build_memory(); diff --git a/fluree-db-api/tests/it_policy_write_path.rs b/fluree-db-api/tests/it_policy_write_path.rs index 4e51970816..6679efd68f 100644 --- a/fluree-db-api/tests/it_policy_write_path.rs +++ b/fluree-db-api/tests/it_policy_write_path.rs @@ -472,3 +472,280 @@ async fn cross_ledger_plus_identity_fails_closed_on_writes() { "expected fail-closed diagnostic mentioning both, got: {msg}" ); } + +/// Identity + cross-ledger `f:policySource` works when a policy class is +/// available (here from D's config): M's rules load via the class filter, +/// the identity is bind-only, and enforcement applies. This is the common +/// authenticated-deployment case — previously it failed closed even though +/// the config named the governing class. +#[tokio::test] +async fn cross_ledger_identity_with_config_policy_class_enforced_on_writes() { + assert_index_defaults(); + let fluree = FlureeBuilder::memory().build_memory(); + + let model_id = "policy/write-xledger-idclass/model:main"; + let model = genesis_ledger(&fluree, model_id); + let policy_graph_iri = "http://example.org/m-policies"; + fluree + .stage_owned(model) + .upsert_turtle(&format!( + r" + @prefix f: . + @prefix rdf: . + @prefix ex: . + + GRAPH <{policy_graph_iri}> {{ + ex:noSsnWrite + rdf:type f:AccessPolicy ; + f:required true ; + f:onProperty ex:ssn ; + f:action f:modify ; + f:allow false . + }} + " + )) + .execute() + .await + .expect("seed M policy graph"); + + let data_id = "policy/write-xledger-idclass/data:main"; + let data = genesis_ledger(&fluree, data_id); + // The identity must exist as a subject in D for ?$identity binding. + let r1 = fluree + .insert( + data, + &json!({ + "@context": {"ex": "http://example.org/ns/"}, + "@graph": [ + {"@id": "ex:alice", "@type": "ex:User", "ex:ssn": "111-11-1111"}, + {"@id": "ex:aliceIdentity", "ex:user": {"@id": "ex:alice"}} + ] + }), + ) + .await + .expect("seed D data + identity"); + + let config_iri = config_graph_iri(data_id); + let r2 = fluree + .stage_owned(r1.ledger) + .upsert_turtle(&format!( + r" + @prefix f: . + @prefix rdf: . + + GRAPH <{config_iri}> {{ + rdf:type f:LedgerConfig . + f:policyDefaults . + f:defaultAllow true . + f:policyClass f:AccessPolicy . + f:policySource . + rdf:type f:GraphRef ; + f:graphSource . + f:ledger <{model_id}> ; + f:graphSelector <{policy_graph_iri}> . + }} + " + )) + .execute() + .await + .expect("seed D cross-ledger config with policyClass"); + let ledger = r2.ledger; + + // Identity-carrying request: must build (not fail closed) because the + // config's f:policyClass selects M's rules; the identity binds only. + // + // default_allow is set on the request: identity counts as a policy + // input, so under the default f:OverrideAll the request's options take + // precedence and the config's f:defaultAllow is NOT merged (same + // long-standing contract as same-ledger reads). Operators who want the + // config to always win set f:overrideControl accordingly. + let opts = GovernanceOptions { + identity: Some("http://example.org/ns/aliceIdentity".into()), + default_allow: true, + ..Default::default() + }; + let ctx = build_transact_policy_context( + &fluree, + &ledger.snapshot, + ledger.novelty.as_ref(), + Some(ledger.novelty.as_ref()), + ledger.t(), + &opts, + ) + .await + .expect("identity + config policyClass must not fail closed") + .expect("cross-ledger source must produce a policy context"); + + let cfg = test_index_config(); + let denied_turtle = "@prefix ex: .\nex:bob ex:ssn \"999-99-9999\" .\n"; + let denied = fluree + .insert_turtle_with_opts( + ledger.clone(), + denied_turtle, + TxnOpts::default(), + CommitOpts::default(), + &cfg, + Some(&ctx), + ) + .await; + assert!( + denied.is_err(), + "M's modify-deny on ex:ssn must reject the identity-carrying write, got: {denied:?}" + ); + + let allowed_turtle = "@prefix ex: .\nex:bob ex:name \"Bob\" .\n"; + let allowed = fluree + .insert_turtle_with_opts( + ledger, + allowed_turtle, + TxnOpts::default(), + CommitOpts::default(), + &cfg, + Some(&ctx), + ) + .await; + assert!( + allowed.is_ok(), + "defaultAllow=true must let untargeted writes through, got: {:?}", + allowed.err() + ); +} + +/// The `?$identity` binding actually drives f:query rules from M: an +/// owner-only modify rule in the model ledger allows the identity to write +/// its own user's email and rejects writes to anyone else's. This pins the +/// bind-only contract — the identity resolves in D and feeds `?$identity`, +/// while the rule itself lives exclusively in M. +#[tokio::test] +async fn cross_ledger_identity_binding_drives_fquery_modify_rule() { + assert_index_defaults(); + let fluree = FlureeBuilder::memory().build_memory(); + + let model_id = "policy/write-xledger-fquery/model:main"; + let model = genesis_ledger(&fluree, model_id); + let policy_graph_iri = "http://example.org/m-policies"; + // Full IRIs inside f:query — it executes against D, where prefixed + // names from M's turtle context wouldn't expand. + let owner_query = + r#"{"where": {"@id": "?$identity", "http://example.org/ns/user": {"@id": "?$this"}}}"#; + fluree + .stage_owned(model) + .upsert_turtle(&format!( + r#" + @prefix f: . + @prefix rdf: . + @prefix ex: . + + GRAPH <{policy_graph_iri}> {{ + ex:ownerEmailOnly + rdf:type f:AccessPolicy ; + f:required true ; + f:onProperty ex:email ; + f:action f:modify ; + f:query """{owner_query}""" . + }} + "# + )) + .execute() + .await + .expect("seed M owner-only f:query rule"); + + let data_id = "policy/write-xledger-fquery/data:main"; + let data = genesis_ledger(&fluree, data_id); + let r1 = fluree + .insert( + data, + &json!({ + "@context": {"ex": "http://example.org/ns/"}, + "@graph": [ + {"@id": "ex:alice", "ex:email": "alice@flur.ee"}, + {"@id": "ex:bob", "ex:email": "bob@flur.ee"}, + {"@id": "ex:aliceIdentity", "ex:user": {"@id": "ex:alice"}} + ] + }), + ) + .await + .expect("seed D users + identity"); + + let config_iri = config_graph_iri(data_id); + let r2 = fluree + .stage_owned(r1.ledger) + .upsert_turtle(&format!( + r" + @prefix f: . + @prefix rdf: . + + GRAPH <{config_iri}> {{ + rdf:type f:LedgerConfig . + f:policyDefaults . + f:defaultAllow true . + f:policyClass f:AccessPolicy . + f:policySource . + rdf:type f:GraphRef ; + f:graphSource . + f:ledger <{model_id}> ; + f:graphSelector <{policy_graph_iri}> . + }} + " + )) + .execute() + .await + .expect("seed D cross-ledger config"); + let ledger = r2.ledger; + + let opts = GovernanceOptions { + identity: Some("http://example.org/ns/aliceIdentity".into()), + ..Default::default() + }; + let ctx = build_transact_policy_context( + &fluree, + &ledger.snapshot, + ledger.novelty.as_ref(), + Some(ledger.novelty.as_ref()), + ledger.t(), + &opts, + ) + .await + .expect("build") + .expect("cross-ledger source must produce a policy context"); + + let cfg = test_index_config(); + // aliceIdentity owns ex:alice → writing alice's email matches the + // ?$identity → ex:user → ?$this chain and is allowed. + let own_turtle = + "@prefix ex: .\nex:alice ex:email \"new-alice@flur.ee\" .\n"; + let own = fluree + .insert_turtle_with_opts( + ledger.clone(), + own_turtle, + TxnOpts::default(), + CommitOpts::default(), + &cfg, + Some(&ctx), + ) + .await; + assert!( + own.is_ok(), + "identity must be able to write its own user's email via M's f:query rule, got: {:?}", + own.err() + ); + + // bob is not aliceIdentity's user → the required rule's f:query binds + // nothing → rejected. + let other_turtle = + "@prefix ex: .\nex:bob ex:email \"hacked@flur.ee\" .\n"; + let other = fluree + .insert_turtle_with_opts( + ledger, + other_turtle, + TxnOpts::default(), + CommitOpts::default(), + &cfg, + Some(&ctx), + ) + .await; + assert!( + other.is_err(), + "identity must NOT be able to write another user's email, got: {other:?}" + ); +} From 394458467f7a6fdb5f279d55832f72421ea1007b Mon Sep 17 00:00:00 2001 From: bplatz Date: Sun, 5 Jul 2026 08:30:06 -0400 Subject: [PATCH 3/5] fix(policy): validate same-ledger f:policySource before no-inputs shortcut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The write-path builder (build_transact_policy_context) applied the !has_any_policy_inputs() early-return before resolving the same-ledger policy source graphs, whereas the read path (wrap_policy) resolves them unconditionally. A config declaring only an invalid f:policySource selector — no f:policyClass/f:defaultAllow, empty request opts — thus failed closed on reads but silently ran as root on writes: the exact read/write divergence this path exists to eliminate. Hoist resolve_policy_source_g_ids above the shortcut so an unknown selector fails closed on writes too. Add a regression test covering the invalid-source + empty-opts case (verified to fail against the prior gate-first ordering). --- fluree-db-api/src/policy_view.rs | 8 ++- fluree-db-api/tests/it_policy_write_path.rs | 70 +++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/fluree-db-api/src/policy_view.rs b/fluree-db-api/src/policy_view.rs index 3379e5ef48..1f8b6ad23e 100644 --- a/fluree-db-api/src/policy_view.rs +++ b/fluree-db-api/src/policy_view.rs @@ -402,11 +402,17 @@ pub async fn build_transact_policy_context( return Ok(Some(policy_ctx)); } + // Resolve (and validate) the same-ledger selector first, matching the read + // path's fail-closed-on-unknown-selector contract (fluree_ext.rs resolves + // unconditionally). Applying the no-inputs shortcut before this would let an + // invalid config `f:policySource` silently run as root on writes while reads + // fail closed — the read/write divergence this path exists to eliminate. + let policy_graphs = policy_builder::resolve_policy_source_g_ids(source, snapshot)?; + if !effective_opts.has_any_policy_inputs() { return Ok(None); } - let policy_graphs = policy_builder::resolve_policy_source_g_ids(source, snapshot)?; let policy_ctx = policy_builder::build_policy_context_from_opts( snapshot, overlay, diff --git a/fluree-db-api/tests/it_policy_write_path.rs b/fluree-db-api/tests/it_policy_write_path.rs index 6679efd68f..68881bb0d4 100644 --- a/fluree-db-api/tests/it_policy_write_path.rs +++ b/fluree-db-api/tests/it_policy_write_path.rs @@ -272,6 +272,76 @@ async fn config_policy_source_named_graph_enforced_on_writes() { ); } +/// A same-ledger `f:policySource` with an *invalid* graph selector must +/// fail closed on writes even when the request carries no policy inputs +/// and config declares no `f:defaultAllow`/`f:policyClass`. The read path +/// (`wrap_policy`) resolves the selector unconditionally and errors on an +/// unknown graph; the write builder must do the same rather than silently +/// running under root — the read/write divergence this path eliminates. +#[tokio::test] +async fn config_invalid_policy_source_fails_closed_on_writes() { + assert_index_defaults(); + let fluree = FlureeBuilder::memory().build_memory(); + let ledger_id = "policy/write-invalid-source:main"; + let ledger0 = genesis_ledger(&fluree, ledger_id); + + let r1 = fluree + .insert( + ledger0, + &json!({ + "@context": {"ex": "http://example.org/ns/"}, + "@id": "ex:alice", + "@type": "ex:User" + }), + ) + .await + .expect("seed data"); + + // Config declares ONLY a policySource whose graph selector points at a + // graph that does not exist in this ledger's registry. No policyClass + // or defaultAllow — so the request has no policy inputs and the pre-fix + // no-inputs shortcut would have returned Ok(None) (root) before ever + // validating the selector. + let missing_graph_iri = "http://example.org/nonexistent-policy-graph"; + let config_iri = config_graph_iri(ledger_id); + let r2 = fluree + .stage_owned(r1.ledger) + .upsert_turtle(&format!( + r" + @prefix f: . + @prefix rdf: . + + GRAPH <{config_iri}> {{ + rdf:type f:LedgerConfig . + f:policyDefaults . + f:policySource . + rdf:type f:GraphRef ; + f:graphSource . + f:graphSelector <{missing_graph_iri}> . + }} + " + )) + .execute() + .await + .expect("seed config"); + let ledger = r2.ledger; + + let result = build_transact_policy_context( + &fluree, + &ledger.snapshot, + ledger.novelty.as_ref(), + Some(ledger.novelty.as_ref()), + ledger.t(), + &GovernanceOptions::default(), + ) + .await; + + assert!( + result.is_err(), + "an invalid same-ledger f:policySource must fail closed on writes, got: {result:?}" + ); +} + /// Cross-ledger `f:policySource`: model ledger M holds the policy rules; /// data ledger D's config points at M. A write to D that violates M's /// modify rules must be rejected — with NO policy inputs on the request. From 4f8f8c83a8c3947a86b7255161a4043b2a55f441 Mon Sep 17 00:00:00 2001 From: bplatz Date: Sun, 5 Jul 2026 09:05:37 -0400 Subject: [PATCH 4/5] perf(policy): cache write-path config resolution, invalidated on config write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enforcing config-declared policy on writes (the #1416 fix) requires reading the ledger #config graph on every transaction — including writes that carry no policy inputs. Unconfigured ledgers short-circuit (O(1) guard), but a configured ledger re-resolved identical config on every write and every stage/commit retry, re-reading state that changes only when config changes. Cache the resolved config per-ledger on LedgerHandle, keyed by a new novelty config-write marker (Novelty::config_write_t) that advances iff a commit touches CONFIG_GRAPH_ID (set in both apply_commit and bulk_apply_commits). A configured-but-static ledger now resolves config once per config change, not once per write; retries triggered by unrelated data conflicts leave the marker untouched and hit the cache. Fail-safe by construction (this is a policy path): the cache is consulted only at head, with a readable marker and a loaded handle. Time-travel, a non-Novelty overlay, no loaded handle, or a marker mismatch (including the downward reset after reindex) all resolve fresh. A miss or bug degrades to an extra resolve, never a stale (fail-open) read. The marker guarantees content identity: a matching marker means the config graph is unchanged. Tests: novelty marker semantics (advances only on config-graph writes, monotonic, bulk path); LedgerHandle cache marker-gating + no-config memoization; verified end-to-end via the consensus write path (cache hits on repeated writes to a live handle). grp_policy 57, grp_transact 159, novelty 60, consensus 48 green; workspace check + all-features clippy clean. --- docs/security/policy-in-transactions.md | 1 + fluree-db-api/src/ledger_manager.rs | 83 +++++++++++++++++++++++++ fluree-db-api/src/policy_view.rs | 82 +++++++++++++++++++++--- fluree-db-novelty/src/lib.rs | 80 +++++++++++++++++++++++- 4 files changed, 235 insertions(+), 11 deletions(-) diff --git a/docs/security/policy-in-transactions.md b/docs/security/policy-in-transactions.md index 53c2181377..6188fb9e74 100644 --- a/docs/security/policy-in-transactions.md +++ b/docs/security/policy-in-transactions.md @@ -248,6 +248,7 @@ Every committed transaction carries the asserting identity in its commit metadat - **Required policies short-circuit.** A failure rejects the transaction immediately without checking remaining flakes. - **Batch transactions amortize loading.** Loading the policy set is per-transaction, not per-flake — large batched transactions pay the load cost once. - **Cache identity properties.** The identity's `@type`, `f:policyClass`, and any role tags used in `f:query` are loaded once per transaction. +- **Config resolution is memoized.** Learning whether a ledger declares policy requires reading its `#config` graph, which the write path now does on every transaction. That read is cached per-ledger and invalidated only when a commit actually writes the config graph, so a configured-but-static ledger under sustained writes resolves its config once per config change — not once per write (nor once per stage/commit retry). Unconfigured ledgers short-circuit before any scan. The cache is a fail-safe fast path: it is consulted only at head with a live handle, and any miss or ambiguity resolves fresh, so it can never serve stale (fail-open) policy. ## Testing policies from the CLI diff --git a/fluree-db-api/src/ledger_manager.rs b/fluree-db-api/src/ledger_manager.rs index 7e1b67c53a..84d04c64b4 100644 --- a/fluree-db-api/src/ledger_manager.rs +++ b/fluree-db-api/src/ledger_manager.rs @@ -28,6 +28,7 @@ use std::path::PathBuf; use fluree_db_binary_index::{BinaryIndexStore, LeafletCache}; use fluree_db_core::db::{LedgerSnapshot, LedgerSnapshotMetadata}; +use fluree_db_core::ledger_config::LedgerConfig; use fluree_db_core::dict_novelty::DictNovelty; use fluree_db_core::trace_commits_by_id; use fluree_db_core::{ledger_id::normalize_ledger_id, ContentId, ContentStore, StorageBackend}; @@ -143,9 +144,26 @@ impl Clone for LedgerHandle { } } +/// Cached resolved ledger config, keyed by the novelty config-write marker +/// (`Novelty::config_write_t`). The marker advances iff a commit writes the +/// config graph, so a matching key guarantees the cached config is current — +/// and a mismatch (including the downward reset after a reindex) forces a +/// re-resolve rather than a stale read. See `policy_view::resolve_ledger_config_cached`. +#[derive(Default)] +struct ConfigCacheEntry { + /// The `config_write_t` this entry was resolved at; `None` until first populated. + key: Option, + /// Resolved config at `key`. `None` (with `key = Some(_)`) memoizes the + /// "ledger declares no config" result so unconfigured ledgers skip the scan. + config: Option>, +} + /// Lock ordering invariant: always acquire `state` before `binary_store`. /// All paths that touch both locks (snapshot, apply_index_v2, reload) /// follow this order to prevent deadlock and ensure coherence. +/// +/// `config_cache` is independent of `state` (guarded by its own lock, never +/// held across the `state` lock), so it participates in no ordering constraint. struct LedgerHandleInner { /// Guards all access to the ledger state. A `RwLock` so concurrent reads /// (every query takes a brief shared `read()` to clone a cheap, Arc-backed @@ -175,6 +193,9 @@ struct LedgerHandleInner { /// latency-sensitive transactor can disable it. Never fires on the commit /// path (commits use `LedgerWriteGuard`, not `snapshot`). tier_width: AtomicUsize, + /// Resolved-config cache, invalidated by the novelty config-write marker. + /// Independent of `state` (see the lock-ordering note above). + config_cache: RwLock, } impl LedgerHandle { @@ -191,10 +212,29 @@ impl LedgerHandle { last_access: AtomicU64::new(monotonic_secs()), binary_store: RwLock::new(binary_store), tier_width: AtomicUsize::new(fluree_db_novelty::DEFAULT_TIER_WIDTH), + config_cache: RwLock::new(ConfigCacheEntry::default()), }), } } + /// Fetch the cached resolved config if it was resolved at `key` (the + /// current `Novelty::config_write_t`). Returns `Some(cfg_opt)` on a hit + /// (`cfg_opt` is `None` when the ledger declares no config), or `None` on a + /// miss. Cheap: a single shared lock on the config cache, never the state. + pub(crate) async fn config_cache_get(&self, key: i64) -> Option>> { + let entry = self.inner.config_cache.read().await; + (entry.key == Some(key)).then(|| entry.config.clone()) + } + + /// Store the resolved config under its config-write marker. A later config + /// write advances the marker, so the next read misses and re-resolves — the + /// cache is a fail-safe fast path that can never serve stale config. + pub(crate) async fn config_cache_put(&self, key: i64, config: Option>) { + let mut entry = self.inner.config_cache.write().await; + entry.key = Some(key); + entry.config = config; + } + /// Set the read-side tier width (`0`/`1` disables read-triggered compaction). /// Policy hook: long-lived servers / query nodes keep the default; an /// insert-only or latency-sensitive transactor can disable it. @@ -2840,4 +2880,47 @@ mod tests { assert_eq!(extracted.status_code(), 404); assert!(extracted.to_string().contains("ledger bar")); } + + /// The resolved-config cache is a fail-safe fast path: a value is served + /// only on an exact config-write-marker match. A newer marker (config + /// changed) misses and forces a re-resolve — it never serves stale config. + /// A memoized "no config" result is a hit (unconfigured ledgers skip the + /// scan), and a `put` at a new marker supersedes the prior entry. + #[tokio::test] + async fn config_cache_marker_gated_and_fail_safe() { + let handle = make_test_handle("cfg:main"); + + // Empty cache: every marker misses. + assert!( + handle.config_cache_get(0).await.is_none(), + "empty cache misses" + ); + + // Store a resolved config at marker 7. + let cfg = Arc::new(LedgerConfig::default()); + handle.config_cache_put(7, Some(Arc::clone(&cfg))).await; + assert!( + matches!(handle.config_cache_get(7).await, Some(Some(_))), + "exact marker hits and returns the stored config" + ); + + // A config write advances the marker → the old entry no longer matches, + // so the read misses and the caller re-resolves (never a stale read). + assert!( + handle.config_cache_get(8).await.is_none(), + "advanced marker invalidates the cached config" + ); + + // "No config at this marker" is a genuine hit, not a miss. + handle.config_cache_put(9, None).await; + assert!( + matches!(handle.config_cache_get(9).await, Some(None)), + "memoized no-config result is a hit" + ); + // The overwrite dropped the marker-7 entry. + assert!( + handle.config_cache_get(7).await.is_none(), + "put supersedes the prior marker entry" + ); + } } diff --git a/fluree-db-api/src/policy_view.rs b/fluree-db-api/src/policy_view.rs index 1f8b6ad23e..8e7546e264 100644 --- a/fluree-db-api/src/policy_view.rs +++ b/fluree-db-api/src/policy_view.rs @@ -28,6 +28,7 @@ use crate::dataset::GovernanceOptions; use crate::error::Result; use crate::policy_builder; +use fluree_db_core::ledger_config::LedgerConfig; use fluree_db_core::{LedgerSnapshot, OverlayProvider}; use fluree_db_ledger::{HistoricalLedgerView, LedgerState}; use fluree_db_novelty::Novelty; @@ -353,16 +354,11 @@ pub async fn build_transact_policy_context( to_t: i64, opts: &GovernanceOptions, ) -> Result> { - let resolved = - match crate::config_resolver::resolve_ledger_config(snapshot, overlay, to_t).await { - Ok(Some(c)) => Some(crate::config_resolver::resolve_effective_config(&c, None)), - Ok(None) => None, - Err(e) => { - return Err(crate::error::ApiError::config(format!( - "Failed to load ledger config while resolving transaction policy: {e}" - ))); - } - }; + let raw_config = + resolve_ledger_config_cached(fluree, snapshot, overlay, novelty_for_stats, to_t).await?; + let resolved = raw_config + .as_deref() + .map(|c| crate::config_resolver::resolve_effective_config(c, None)); let effective_opts = match &resolved { Some(r) => crate::config_resolver::merge_policy_opts(r, opts, None), @@ -425,6 +421,72 @@ pub async fn build_transact_policy_context( Ok(Some(policy_ctx)) } +/// Resolve the raw ledger config for the write path, memoized per-ledger by the +/// novelty config-write marker (`Novelty::config_write_t`). +/// +/// Reading the config graph on every write — including writes that carry no +/// policy inputs — is feature-necessary (you must read config to learn +/// `f:policySource` / config policy defaults), but for a configured ledger under +/// sustained writes it re-resolves state that has not changed. The marker +/// advances iff a commit touches the config graph, so a configured-but-static +/// ledger resolves config once per config change instead of once per write (and +/// once per stage/commit retry — retries triggered by unrelated data conflicts +/// leave the marker untouched and hit the cache). +/// +/// Fail-safe by construction: the cache is consulted only at head, with a +/// readable marker and a loaded handle. Any deviation — time-travel (`to_t` +/// below head), a non-`Novelty` overlay, or no loaded handle — resolves fresh +/// against the passed snapshot/overlay. A cache miss or a marker reset (e.g. +/// after reindex) costs an extra resolve, never a stale (fail-open) read. +async fn resolve_ledger_config_cached( + fluree: &crate::Fluree, + snapshot: &LedgerSnapshot, + overlay: &dyn OverlayProvider, + novelty_for_stats: Option<&Novelty>, + to_t: i64, +) -> Result>> { + // The invalidation marker and head detection both come from the current + // novelty overlay. Prefer the explicit stats handle; fall back to the + // overlay when it is itself a `Novelty`. + let novelty = novelty_for_stats.or_else(|| overlay.as_any().downcast_ref::()); + + // Only cacheable at head (`to_t` == ledger head) with a readable marker. + let cache_key = novelty.and_then(|nov| { + let head_t = snapshot.t.max(nov.t); + (to_t == head_t).then_some(nov.config_write_t) + }); + + if let Some(key) = cache_key { + if let Some(mgr) = fluree.ledger_manager() { + if let Some(handle) = mgr.get_loaded_handle(&snapshot.ledger_id).await { + if let Some(hit) = handle.config_cache_get(key).await { + return Ok(hit); + } + let resolved = resolve_ledger_config_raw(snapshot, overlay, to_t).await?; + handle.config_cache_put(key, resolved.clone()).await; + return Ok(resolved); + } + } + } + + resolve_ledger_config_raw(snapshot, overlay, to_t).await +} + +/// Uncached resolve, `Arc`-wrapping the result and mapping the error into the +/// config-failure shape `build_transact_policy_context` reports. +async fn resolve_ledger_config_raw( + snapshot: &LedgerSnapshot, + overlay: &dyn OverlayProvider, + to_t: i64, +) -> Result>> { + match crate::config_resolver::resolve_ledger_config(snapshot, overlay, to_t).await { + Ok(opt) => Ok(opt.map(Arc::new)), + Err(e) => Err(crate::error::ApiError::config(format!( + "Failed to load ledger config while resolving transaction policy: {e}" + ))), + } +} + /// Wrap a ledger with identity-based policy via `f:policyClass` lookup. /// /// Convenience wrapper for identity-based policy wrapping. diff --git a/fluree-db-novelty/src/lib.rs b/fluree-db-novelty/src/lib.rs index a705d02f83..0d62754d3c 100644 --- a/fluree-db-novelty/src/lib.rs +++ b/fluree-db-novelty/src/lib.rs @@ -63,7 +63,7 @@ pub use runtime_stats::{ pub use stats::current_stats; use fact_state::NoveltyFactState; -use fluree_db_core::{Flake, GraphId, IndexType, Sid}; +use fluree_db_core::{Flake, GraphId, IndexType, Sid, CONFIG_GRAPH_ID}; use std::cmp::Ordering; use std::collections::{BinaryHeap, HashMap, HashSet}; use std::sync::Arc; @@ -476,6 +476,15 @@ pub struct Novelty { /// Epoch for cache invalidation - bumped once per commit pub epoch: u64, + /// Highest `commit_t` at which the ledger config graph (`CONFIG_GRAPH_ID`) + /// received a write. Monotonic within a novelty window; resets to 0 when + /// novelty is rebuilt (e.g. reindex) — a downward reset only forces a + /// re-resolve, never a stale read. Used as the fail-safe invalidation key + /// for the resolved-config cache on `LedgerHandle`: config content at head + /// is fully determined by this marker, so a matching marker guarantees the + /// cached config is current. Stays 0 for ledgers that never write config. + pub config_write_t: i64, + /// Edge-annotation attachment overlay (M1 — derived from the /// `f:reifies*` system flakes flowing through the same pipeline). /// Updated automatically by [`Self::apply_commit`] / @@ -497,6 +506,7 @@ impl Novelty { flake_count: 0, t, epoch: 0, + config_write_t: 0, attachments: AttachmentNovelty::new(), fact_state: NoveltyFactState::new(), } @@ -801,6 +811,12 @@ impl Novelty { self.t = self.t.max(commit_t); self.epoch += 1; // Bump epoch once per commit + // Advance the config-graph write marker so the resolved-config cache + // (see `LedgerHandle`) invalidates iff this commit touched config. + if checked.contains(&CONFIG_GRAPH_ID) { + self.config_write_t = self.config_write_t.max(commit_t); + } + // RDF set semantics: skip assertion flakes whose fact (s, p, o, dt, m) is // already **currently asserted** in this graph's novelty window. This // prevents duplicate facts from accumulating when the same triple is @@ -1006,6 +1022,13 @@ impl Novelty { // somehow exceeds the local-index width). self.set_graph_segments(g_id, kept, true); + // Keep the config-graph write marker current on the bulk path too + // (mirrors `apply_commit`), so the resolved-config cache invalidates + // after a bulk import that writes config. + if g_id == CONFIG_GRAPH_ID { + self.config_write_t = self.config_write_t.max(max_t); + } + // Update attachment overlay after the per-graph batch is committed. // Malformed bundles are skipped + warned + counted on // `attachments.observed_malformed_bundle_count` (see @@ -2166,6 +2189,61 @@ mod tests { ); } + // ===== Config-graph write marker (resolved-config cache invalidation) ===== + + /// `config_write_t` must advance to `commit_t` exactly when a commit touches + /// `CONFIG_GRAPH_ID`, stay put on data-only commits, and never regress. This + /// is the fail-safe invalidation key for the resolved-config cache: a + /// matching marker proves config is unchanged, and a data-only write can + /// never spuriously invalidate (churn) nor a config write fail to (stale). + #[test] + fn config_write_marker_tracks_only_config_graph() { + let cfg_g = Sid::new(9, "config-graph"); + let mut rg = HashMap::new(); + rg.insert(cfg_g.clone(), CONFIG_GRAPH_ID); + + let mut n = Novelty::new(0); + assert_eq!(n.config_write_t, 0, "marker starts at 0"); + + // Data-only commit (default graph, g = None) must not move the marker. + n.apply_commit(vec![make_flake(1, 1, 1, 5, true)], 5, &rg) + .unwrap(); + assert_eq!(n.config_write_t, 0, "default-graph write leaves marker"); + + // A commit touching the config graph advances the marker to commit_t. + n.apply_commit(vec![make_graph_flake(2, 1, 1, 7, cfg_g.clone())], 7, &rg) + .unwrap(); + assert_eq!(n.config_write_t, 7, "config write advances marker to commit_t"); + + // A later data-only commit must neither bump nor regress the marker. + n.apply_commit(vec![make_flake(3, 1, 1, 9, true)], 9, &rg) + .unwrap(); + assert_eq!(n.config_write_t, 7, "later data-only write leaves marker"); + } + + /// The bulk (cold-load) path maintains the same marker as `apply_commit`. + #[test] + fn config_write_marker_tracks_config_graph_on_bulk() { + let cfg_g = Sid::new(9, "config-graph"); + let mut rg = HashMap::new(); + rg.insert(cfg_g.clone(), CONFIG_GRAPH_ID); + + let mut n = Novelty::new(0); + n.bulk_apply_commits( + vec![(vec![make_flake(1, 1, 1, 3, true)], 3)], + &rg, + ) + .unwrap(); + assert_eq!(n.config_write_t, 0, "bulk data-only load leaves marker"); + + n.bulk_apply_commits( + vec![(vec![make_graph_flake(2, 1, 1, 8, cfg_g.clone())], 8)], + &rg, + ) + .unwrap(); + assert_eq!(n.config_write_t, 8, "bulk config load advances marker"); + } + /// A same-`t` assert+retract of one identity must resolve to ABSENT in /// `fact_state` (retract wins, matching overlay lifecycle resolution), so a /// later re-assert is KEPT, not silently deduped. Stage-level cancellation From 4360e6625686e933370d75f34eb2f40e71d333b1 Mon Sep 17 00:00:00 2001 From: bplatz Date: Sun, 5 Jul 2026 09:30:00 -0400 Subject: [PATCH 5/5] fmt --- fluree-db-api/src/ledger_manager.rs | 2 +- fluree-db-novelty/src/lib.rs | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/fluree-db-api/src/ledger_manager.rs b/fluree-db-api/src/ledger_manager.rs index 84d04c64b4..61ecd54793 100644 --- a/fluree-db-api/src/ledger_manager.rs +++ b/fluree-db-api/src/ledger_manager.rs @@ -28,8 +28,8 @@ use std::path::PathBuf; use fluree_db_binary_index::{BinaryIndexStore, LeafletCache}; use fluree_db_core::db::{LedgerSnapshot, LedgerSnapshotMetadata}; -use fluree_db_core::ledger_config::LedgerConfig; use fluree_db_core::dict_novelty::DictNovelty; +use fluree_db_core::ledger_config::LedgerConfig; use fluree_db_core::trace_commits_by_id; use fluree_db_core::{ledger_id::normalize_ledger_id, ContentId, ContentStore, StorageBackend}; use fluree_db_ledger::{LedgerState, TypeErasedStore}; diff --git a/fluree-db-novelty/src/lib.rs b/fluree-db-novelty/src/lib.rs index 0d62754d3c..717892c6b0 100644 --- a/fluree-db-novelty/src/lib.rs +++ b/fluree-db-novelty/src/lib.rs @@ -2213,7 +2213,10 @@ mod tests { // A commit touching the config graph advances the marker to commit_t. n.apply_commit(vec![make_graph_flake(2, 1, 1, 7, cfg_g.clone())], 7, &rg) .unwrap(); - assert_eq!(n.config_write_t, 7, "config write advances marker to commit_t"); + assert_eq!( + n.config_write_t, 7, + "config write advances marker to commit_t" + ); // A later data-only commit must neither bump nor regress the marker. n.apply_commit(vec![make_flake(3, 1, 1, 9, true)], 9, &rg) @@ -2229,11 +2232,8 @@ mod tests { rg.insert(cfg_g.clone(), CONFIG_GRAPH_ID); let mut n = Novelty::new(0); - n.bulk_apply_commits( - vec![(vec![make_flake(1, 1, 1, 3, true)], 3)], - &rg, - ) - .unwrap(); + n.bulk_apply_commits(vec![(vec![make_flake(1, 1, 1, 3, true)], 3)], &rg) + .unwrap(); assert_eq!(n.config_write_t, 0, "bulk data-only load leaves marker"); n.bulk_apply_commits(